Compare commits
33 Commits
@@ -0,0 +1,12 @@
|
|||||||
|
# Exclude data directory to prevent local database from being copied into production images
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Exclude logs
|
||||||
|
backend/logs/
|
||||||
|
|
||||||
|
# Exclude development files
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
*.log
|
||||||
@@ -576,3 +576,7 @@ backend/alembic/**
|
|||||||
!backend/alembic/script.py.mako
|
!backend/alembic/script.py.mako
|
||||||
!frontend/src/css/lib
|
!frontend/src/css/lib
|
||||||
**/*.module.scss.d.ts
|
**/*.module.scss.d.ts
|
||||||
|
|
||||||
|
.cursor/plans
|
||||||
|
tmp
|
||||||
|
compliance_keypair.txt
|
||||||
@@ -6,5 +6,6 @@
|
|||||||
"**/.husky/_": true,
|
"**/.husky/_": true,
|
||||||
"**/.venv": true,
|
"**/.venv": true,
|
||||||
"**/node_modules": true
|
"**/node_modules": true
|
||||||
}
|
},
|
||||||
|
"python.terminal.activateEnvironment": false
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
@@ -7,17 +7,15 @@
|
|||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
software and other kinds of works.
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
The licenses for most software and other practical works are designed
|
||||||
to take away your freedom to share and change the works. By contrast,
|
to take away your freedom to share and change the works. By contrast,
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
share and change all versions of a program--to make sure it remains free
|
share and change all versions of a program--to make sure it remains free
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
software for all its users.
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
When we speak of free software, we are referring to freedom, not
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
|
|||||||
want it, that you can change the software or use pieces of it in new
|
want it, that you can change the software or use pieces of it in new
|
||||||
free programs, and that you know you can do these things.
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
Developers that use our General Public Licenses protect your rights
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
you this License which gives you legal permission to copy, distribute
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
and/or modify the software.
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
A secondary benefit of defending all users' freedom is that
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
improvements made in alternate versions of the program, if they
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
receive widespread use, become available for other developers to
|
||||||
or can get the source code. And you must show them these terms so they
|
incorporate. Many developers of free software are heartened and
|
||||||
know their rights.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
The GNU Affero General Public License is designed specifically to
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
ensure that, in such cases, the modified source code becomes available
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
An older license, called the Affero General Public License and
|
||||||
that there is no warranty for this free software. For both users' and
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
changed, so that their problems will not be attributed erroneously to
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
authors of previous versions.
|
this license.
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
The precise terms and conditions for copying, distribution and
|
||||||
modification follow.
|
modification follow.
|
||||||
@@ -72,7 +60,7 @@ modification follow.
|
|||||||
|
|
||||||
0. Definitions.
|
0. Definitions.
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
works, such as semiconductor masks.
|
works, such as semiconductor masks.
|
||||||
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
|
|||||||
the Program, the only way you could satisfy both those terms and this
|
the Program, the only way you could satisfy both those terms and this
|
||||||
License would be to refrain entirely from conveying the Program.
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
Notwithstanding any other provision of this License, you have
|
||||||
permission to link or combine any covered work with a work licensed
|
permission to link or combine any covered work with a work licensed
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
under version 3 of the GNU General Public License into a single
|
||||||
combined work, and to convey the resulting work. The terms of this
|
combined work, and to convey the resulting work. The terms of this
|
||||||
License will continue to apply to the part which is the covered work,
|
License will continue to apply to the part which is the covered work,
|
||||||
but the special requirements of the GNU Affero General Public License,
|
but the work with which it is combined will remain governed by version
|
||||||
section 13, concerning interaction through a network will apply to the
|
3 of the GNU General Public License.
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
the GNU General Public License from time to time. Such new versions will
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
address new problems or concerns.
|
address new problems or concerns.
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
Each version is given a distinguishing version number. If the
|
||||||
Program specifies that a certain numbered version of the GNU General
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
Public License "or any later version" applies to it, you have the
|
Public License "or any later version" applies to it, you have the
|
||||||
option of following the terms and conditions either of that numbered
|
option of following the terms and conditions either of that numbered
|
||||||
version or of any later version published by the Free Software
|
version or of any later version published by the Free Software
|
||||||
Foundation. If the Program does not specify a version number of the
|
Foundation. If the Program does not specify a version number of the
|
||||||
GNU General Public License, you may choose any version ever published
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
by the Free Software Foundation.
|
by the Free Software Foundation.
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
If the Program specifies that a proxy can decide which future
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
public statement of acceptance of a version permanently authorizes you
|
public statement of acceptance of a version permanently authorizes you
|
||||||
to choose that version for the Program.
|
to choose that version for the Program.
|
||||||
|
|
||||||
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||||||
Copyright (C) <year> <name of author>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU Affero General Public License as published
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
This program is distributed in the hope that it will be useful,
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
GNU General Public License for more details.
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU Affero General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
If your software can interact with users remotely through a computer
|
||||||
notice like this when it starts in an interactive mode:
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
<program> Copyright (C) <year> <name of author>
|
interface could display a "Source" link that leads users to an archive
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
of the code. There are many ways you could offer source, and different
|
||||||
This is free software, and you are welcome to redistribute it
|
solutions will be better for different programs; see section 13 for the
|
||||||
under certain conditions; type `show c' for details.
|
specific requirements.
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
<https://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||||
import logging
|
import logging
|
||||||
from models import User
|
from models import User
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
@@ -20,9 +20,10 @@ from slowapi.middleware import SlowAPIMiddleware
|
|||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup - run migration in separate process to avoid logging interference
|
# Startup - run migration in subprocess to avoid logging interference
|
||||||
try:
|
try:
|
||||||
logger.info("Starting database migration check...")
|
logger.info("Starting database migration check...")
|
||||||
# Run migration in a separate process
|
# Run migration in a separate process
|
||||||
@@ -40,7 +41,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
owner = db.query(User).filter(User.username == OWNER_USERNAME).first()
|
owner = db.query(User).filter(User.id == 1).first()
|
||||||
if owner and not owner.verified:
|
if owner and not owner.verified:
|
||||||
owner.verified = True
|
owner.verified = True
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -108,6 +109,16 @@ app.add_middleware(SlowAPIMiddleware)
|
|||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def access_logging_middleware(request: Request, call_next):
|
async def access_logging_middleware(request: Request, call_next):
|
||||||
|
# Log incoming request and Authorization header presence for debugging auth issues
|
||||||
|
try:
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
if auth_header:
|
||||||
|
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
|
||||||
|
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
|
||||||
|
else:
|
||||||
|
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
@@ -165,3 +176,4 @@ app.include_router(push.router, prefix="/push")
|
|||||||
app.include_router(webrtc.router, prefix="/webrtc")
|
app.include_router(webrtc.router, prefix="/webrtc")
|
||||||
app.include_router(devices.router, prefix="/devices")
|
app.include_router(devices.router, prefix="/devices")
|
||||||
app.include_router(moderation.router)
|
app.include_router(moderation.router)
|
||||||
|
app.include_router(download.router)
|
||||||
@@ -5,8 +5,10 @@ from sqlalchemy.orm import Session
|
|||||||
from utils import verify_token
|
from utils import verify_token
|
||||||
from models import User, DeviceSession
|
from models import User, DeviceSession
|
||||||
from db import SessionLocal
|
from db import SessionLocal
|
||||||
|
import logging
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
# Зависимость для получения сессии БД
|
# Зависимость для получения сессии БД
|
||||||
def get_db():
|
def get_db():
|
||||||
@@ -23,8 +25,17 @@ def get_current_user(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> User:
|
) -> User:
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
try:
|
||||||
payload = verify_token(token)
|
payload = verify_token(token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("get_current_user: token verification error: %s", str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
logger.info("get_current_user: verify_token returned empty payload")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid or expired token",
|
detail="Invalid or expired token",
|
||||||
@@ -32,6 +43,7 @@ def get_current_user(
|
|||||||
)
|
)
|
||||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||||
if not user:
|
if not user:
|
||||||
|
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="User not found",
|
detail="User not found",
|
||||||
@@ -60,6 +72,7 @@ def get_current_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not device_session or device_session.revoked:
|
if not device_session or device_session.revoked:
|
||||||
|
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Session revoked or not found",
|
detail="Session revoked or not found",
|
||||||
@@ -73,6 +86,7 @@ def get_current_user(
|
|||||||
# Session expired due to inactivity - revoke it
|
# Session expired due to inactivity - revoke it
|
||||||
device_session.revoked = True
|
device_session.revoked = True
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Session expired due to inactivity",
|
detail="Session expired due to inactivity",
|
||||||
@@ -85,6 +99,7 @@ def get_current_user(
|
|||||||
|
|
||||||
# Check if user is suspended
|
# Check if user is suspended
|
||||||
if user.suspended:
|
if user.suspended:
|
||||||
|
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Account suspended",
|
detail="Account suspended",
|
||||||
@@ -93,6 +108,7 @@ def get_current_user(
|
|||||||
|
|
||||||
# Check if user is deleted
|
# Check if user is deleted
|
||||||
if user.deleted:
|
if user.deleted:
|
||||||
|
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Account deleted",
|
detail="Account deleted",
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ def run_migrations():
|
|||||||
Fully automated - handles all scenarios automatically.
|
Fully automated - handles all scenarios automatically.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
with engine.connect() as connection:
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
inspector = inspect(connection)
|
||||||
|
existing_tables = [table for table in inspector.get_table_names()
|
||||||
|
if not table.startswith('sqlite_') and table != 'alembic_version']
|
||||||
|
|
||||||
|
# If no application tables exist, create them directly from models
|
||||||
|
if not existing_tables:
|
||||||
|
logger.info("No application tables found. Creating all tables directly from models...")
|
||||||
|
from models import Base
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("All tables created successfully from models.")
|
||||||
|
|
||||||
# Get the directory where this script is located
|
# Get the directory where this script is located
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ class PushSubscription(Base):
|
|||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
|
class FcmToken(Base):
|
||||||
|
__tablename__ = "fcm_token"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
|
||||||
|
token = Column(Text, nullable=False, unique=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||||
|
|
||||||
|
|
||||||
class Reaction(Base):
|
class Reaction(Base):
|
||||||
__tablename__ = "reaction"
|
__tablename__ = "reaction"
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ from typing import List, Optional
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pywebpush import webpush, WebPushException
|
from pywebpush import webpush, WebPushException
|
||||||
from models import PushSubscription, User, Message, DMEnvelope
|
from models import PushSubscription, User, Message, DMEnvelope
|
||||||
|
from models import FcmToken
|
||||||
|
import firebase_admin
|
||||||
|
from firebase_admin import credentials as firebase_credentials
|
||||||
|
from firebase_admin import messaging as firebase_messaging
|
||||||
|
import base64
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@@ -12,6 +17,24 @@ class PushNotificationService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
|
||||||
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
|
||||||
|
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
|
||||||
|
self.firebase_initialized = False
|
||||||
|
try:
|
||||||
|
firebase_cert = os.getenv("FIREBASE_CERT")
|
||||||
|
if not firebase_cert:
|
||||||
|
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
|
||||||
|
|
||||||
|
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
|
||||||
|
decoded = base64.b64decode(firebase_cert).decode("utf-8")
|
||||||
|
sa_dict = json.loads(decoded)
|
||||||
|
|
||||||
|
cred = firebase_credentials.Certificate(sa_dict)
|
||||||
|
firebase_admin.initialize_app(cred)
|
||||||
|
self.firebase_initialized = True
|
||||||
|
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
if (not self.vapid_public_key) or (not self.vapid_private_key):
|
||||||
raise ValueError("VAPID public or private key is None")
|
raise ValueError("VAPID public or private key is None")
|
||||||
@@ -60,21 +83,30 @@ class PushNotificationService:
|
|||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
# Check if user has push subscription before trying to send
|
# Check if user has push subscription before trying to send
|
||||||
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
||||||
if not subscription:
|
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
||||||
continue
|
payload_data = {
|
||||||
|
|
||||||
await self._send_notification_to_user(
|
|
||||||
db, user.id,
|
|
||||||
f"New message from {message.author.username}",
|
|
||||||
message.content[:100] + ("..." if len(message.content) > 100 else ""),
|
|
||||||
message.author.profile_picture,
|
|
||||||
{
|
|
||||||
"type": "public_message",
|
"type": "public_message",
|
||||||
"message_id": message.id,
|
"message_id": message.id,
|
||||||
"sender_id": message.user_id,
|
"sender_id": message.user_id,
|
||||||
"sender_username": message.author.username
|
"sender_username": message.author.username
|
||||||
}
|
}
|
||||||
|
title = f"{message.author.username}"
|
||||||
|
body = message.content[:100] + ("..." if len(message.content) > 100 else "")
|
||||||
|
|
||||||
|
if fcm_rows and self.firebase_initialized:
|
||||||
|
for fcm in fcm_rows:
|
||||||
|
try:
|
||||||
|
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}")
|
||||||
|
# Check if this is a permanent failure and clean up the token
|
||||||
|
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||||
|
|
||||||
|
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
|
||||||
|
if subscription:
|
||||||
|
await self._send_notification_to_user(
|
||||||
|
db, user.id, title, body, message.author.profile_picture, payload_data
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send public message notifications: {e}")
|
logger.error(f"Failed to send public message notifications: {e}")
|
||||||
@@ -82,17 +114,27 @@ class PushNotificationService:
|
|||||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||||
"""Send push notification for a new DM"""
|
"""Send push notification for a new DM"""
|
||||||
try:
|
try:
|
||||||
await self._send_notification_to_user(
|
title = f"{sender.username}"
|
||||||
db, dm_envelope.recipient_id,
|
body = "New direct message"
|
||||||
f"New message from {sender.username}",
|
payload_data = {
|
||||||
"You have a new direct message",
|
|
||||||
sender.profile_picture,
|
|
||||||
{
|
|
||||||
"type": "dm",
|
"type": "dm",
|
||||||
"dm_id": dm_envelope.id,
|
"dm_id": dm_envelope.id,
|
||||||
"sender_id": sender.id,
|
"sender_id": sender.id,
|
||||||
"sender_username": sender.username
|
"sender_username": sender.username
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all()
|
||||||
|
if fcm_rows and self.firebase_initialized:
|
||||||
|
for fcm in fcm_rows:
|
||||||
|
try:
|
||||||
|
self._send_fcm_to_token(fcm.token, title, body, payload_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}")
|
||||||
|
# Check if this is a permanent failure and clean up the token
|
||||||
|
self._cleanup_failed_fcm_token(db, fcm, str(e))
|
||||||
|
|
||||||
|
await self._send_notification_to_user(
|
||||||
|
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send DM notification: {e}")
|
logger.error(f"Failed to send DM notification: {e}")
|
||||||
@@ -136,6 +178,56 @@ class PushNotificationService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
logger.error(f"Failed to send push notification to user {user_id}: {e}")
|
||||||
|
|
||||||
|
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
|
||||||
|
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
|
||||||
|
Notification display is handled by the app, not FCM."""
|
||||||
|
if not self.firebase_initialized:
|
||||||
|
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send only data payload - let the app handle notification display
|
||||||
|
# This prevents FCM from auto-showing notifications
|
||||||
|
msg = firebase_messaging.Message(
|
||||||
|
token=token,
|
||||||
|
data={
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
**{k: str(v) for k, v in (data or {}).items()}
|
||||||
|
},
|
||||||
|
android=firebase_messaging.AndroidConfig(priority="high"),
|
||||||
|
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
|
||||||
|
)
|
||||||
|
resp = firebase_messaging.send(msg)
|
||||||
|
return resp
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Firebase Admin send failed for token {token}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
|
||||||
|
"""Clean up FCM tokens that have permanent failures"""
|
||||||
|
try:
|
||||||
|
# Check for permanent failure indicators in the error message
|
||||||
|
permanent_errors = [
|
||||||
|
"unregistered", "invalidregistration", "notregistered",
|
||||||
|
"sender_id_mismatch", "invalid_argument"
|
||||||
|
]
|
||||||
|
|
||||||
|
error_lower = error_message.lower()
|
||||||
|
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
|
||||||
|
|
||||||
|
if is_permanent:
|
||||||
|
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
|
||||||
|
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
|
||||||
"""Unsubscribe a user from push notifications"""
|
"""Unsubscribe a user from push notifications"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ user-agents>=2.2.0
|
|||||||
httpx>=0.27.2
|
httpx>=0.27.2
|
||||||
rich>=13.9.4
|
rich>=13.9.4
|
||||||
slowapi>=0.1.9
|
slowapi>=0.1.9
|
||||||
|
firebase_admin>=7.1.0
|
||||||
@@ -39,6 +39,9 @@ def _record_failed_login(identifier: str) -> bool:
|
|||||||
def _reset_failed_logins(identifier: str) -> None:
|
def _reset_failed_logins(identifier: str) -> None:
|
||||||
_failed_login_attempts.pop(identifier, None)
|
_failed_login_attempts.pop(identifier, None)
|
||||||
|
|
||||||
|
def _is_admin(user: User) -> bool:
|
||||||
|
return user.id == 1
|
||||||
|
|
||||||
def convert_user(user: User) -> dict:
|
def convert_user(user: User) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
@@ -49,7 +52,7 @@ def convert_user(user: User) -> dict:
|
|||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"profile_picture": user.profile_picture,
|
"profile_picture": user.profile_picture,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"admin": user.username == OWNER_USERNAME,
|
"admin": _is_admin(user),
|
||||||
"verified": user.verified,
|
"verified": user.verified,
|
||||||
"suspended": user.suspended or False,
|
"suspended": user.suspended or False,
|
||||||
"suspension_reason": user.suspension_reason,
|
"suspension_reason": user.suspension_reason,
|
||||||
@@ -61,7 +64,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
|||||||
return {
|
return {
|
||||||
"authenticated": True,
|
"authenticated": True,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"admin": current_user.username == OWNER_USERNAME
|
"admin": _is_admin(current_user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -177,13 +180,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
# Determine if owner already exists
|
# Determine if owner already exists
|
||||||
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
|
||||||
|
|
||||||
# If owner not yet registered, only allow the owner to register
|
|
||||||
if not owner_exists and username != OWNER_USERNAME:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Регистрация временно закрыта до регистрации владельца"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate input
|
# Validate input
|
||||||
if not is_valid_username(username):
|
if not is_valid_username(username):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -219,13 +215,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
detail="Пароли не совпадают"
|
detail="Пароли не совпадают"
|
||||||
)
|
)
|
||||||
|
|
||||||
# After owner exists, disallow registering the reserved owner username via public registration
|
|
||||||
if owner_exists and username == OWNER_USERNAME:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Это имя пользователя зарезервировано"
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_user = db.query(User).filter(User.username == username).first()
|
existing_user = db.query(User).filter(User.username == username).first()
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -355,7 +344,7 @@ def delete_user_as_owner(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
# Only owner can delete users
|
# Only owner can delete users
|
||||||
if current_user.username != OWNER_USERNAME:
|
if _is_admin(current_user):
|
||||||
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
raise HTTPException(status_code=403, detail="Only owner can perform this action")
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
@@ -363,7 +352,7 @@ def delete_user_as_owner(
|
|||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
# Prevent deleting the owner account via API
|
# Prevent deleting the owner account via API
|
||||||
if user.username == OWNER_USERNAME:
|
if _is_admin(user):
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
raise HTTPException(status_code=400, detail="Cannot delete owner account")
|
||||||
|
|
||||||
# Manually delete user's messages to satisfy FK constraints
|
# Manually delete user's messages to satisfy FK constraints
|
||||||
@@ -567,7 +556,7 @@ async def delete_account(
|
|||||||
Delete the current user's own account - preserves messages/DMs/reactions/files
|
Delete the current user's own account - preserves messages/DMs/reactions/files
|
||||||
"""
|
"""
|
||||||
# Prevent admin/owner account self-deletion
|
# Prevent admin/owner account self-deletion
|
||||||
if current_user.username == OWNER_USERNAME or current_user.id == 1:
|
if _is_admin(current_user):
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
|
||||||
|
|
||||||
await _delete_user_data(current_user, db)
|
await _delete_user_data(current_user, db)
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
Download routes for FromChat desktop and mobile builds.
|
||||||
|
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/download", tags=["download"])
|
||||||
|
|
||||||
|
GITHUB_API = "https://api.github.com"
|
||||||
|
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
|
||||||
|
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
|
||||||
|
WORKFLOW_FILE = "build.yml"
|
||||||
|
TIMEOUT = 10.0
|
||||||
|
|
||||||
|
ARTIFACT_NAMES = {
|
||||||
|
"windows": "FromChat-windows",
|
||||||
|
"linux": "FromChat-linux",
|
||||||
|
"macos": "FromChat-macOS",
|
||||||
|
}
|
||||||
|
|
||||||
|
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
token = os.environ.get("RELEASES_TOKEN")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _etag_path(os_name: str) -> Path:
|
||||||
|
return CACHE_DIR / f"{os_name}.etag"
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_file_path(os_name: str) -> Path:
|
||||||
|
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
|
||||||
|
return CACHE_DIR / f"{os_name}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
|
||||||
|
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
|
||||||
|
artifact_name = ARTIFACT_NAMES[os_name]
|
||||||
|
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
|
||||||
|
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
|
||||||
|
runs_resp = await client.get(
|
||||||
|
runs_url,
|
||||||
|
headers=_headers(),
|
||||||
|
params={"per_page": 1, "status": "success"},
|
||||||
|
)
|
||||||
|
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
|
||||||
|
runs_resp.raise_for_status()
|
||||||
|
runs = runs_resp.json()
|
||||||
|
workflow_runs = runs.get("workflow_runs", [])
|
||||||
|
if not workflow_runs:
|
||||||
|
logger.warning("[download] No successful workflow runs for %s", artifact_name)
|
||||||
|
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
|
||||||
|
|
||||||
|
run_id = workflow_runs[0]["id"]
|
||||||
|
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
|
||||||
|
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
|
||||||
|
artifacts_resp = await client.get(artifacts_url, headers=_headers())
|
||||||
|
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
|
||||||
|
artifacts_resp.raise_for_status()
|
||||||
|
data = artifacts_resp.json()
|
||||||
|
for artifact in data.get("artifacts", []):
|
||||||
|
if artifact["name"] == artifact_name:
|
||||||
|
url = artifact["archive_download_url"]
|
||||||
|
aid = artifact["id"]
|
||||||
|
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
|
||||||
|
return url, aid
|
||||||
|
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
|
||||||
|
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_mobile_asset_url(os_name: str) -> str:
|
||||||
|
"""Fetch latest release, find asset by name. Returns browser_download_url."""
|
||||||
|
keyword = "android" if os_name == "android" else "ios"
|
||||||
|
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
|
||||||
|
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
|
||||||
|
resp = await client.get(
|
||||||
|
releases_url,
|
||||||
|
headers=_headers(),
|
||||||
|
params={"per_page": 10},
|
||||||
|
)
|
||||||
|
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
|
||||||
|
resp.raise_for_status()
|
||||||
|
releases = resp.json()
|
||||||
|
for release in releases:
|
||||||
|
if release.get("draft"):
|
||||||
|
continue
|
||||||
|
for asset in release.get("assets", []):
|
||||||
|
if keyword.lower() in asset.get("name", "").lower():
|
||||||
|
url = asset["browser_download_url"]
|
||||||
|
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
|
||||||
|
return url
|
||||||
|
logger.warning("[download] No %s asset in releases", os_name)
|
||||||
|
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_and_stream(
|
||||||
|
url: str,
|
||||||
|
os_name: str,
|
||||||
|
stored_etag: str | None,
|
||||||
|
) -> StreamingResponse | FileResponse:
|
||||||
|
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
headers = {**_headers(), "Accept": "*/*"}
|
||||||
|
if stored_etag:
|
||||||
|
headers["If-None-Match"] = stored_etag
|
||||||
|
|
||||||
|
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
|
||||||
|
|
||||||
|
async def stream_and_save():
|
||||||
|
total = 0
|
||||||
|
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||||
|
new_etag: str | None = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", url, headers=headers) as resp:
|
||||||
|
if resp.status_code == 304 and cache_path.exists():
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Release asset not found on GitHub",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="GitHub returned an error while downloading asset",
|
||||||
|
)
|
||||||
|
new_etag = resp.headers.get("etag")
|
||||||
|
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
tmp_path.rename(cache_path)
|
||||||
|
if new_etag:
|
||||||
|
etag_path.write_text(new_etag)
|
||||||
|
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
|
||||||
|
except httpx.StreamClosed:
|
||||||
|
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
if cache_path.exists():
|
||||||
|
raise _CacheFallback()
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
except HTTPException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
class _CacheFallback(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
gen = stream_and_save()
|
||||||
|
try:
|
||||||
|
first = await gen.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
first = None
|
||||||
|
except _CacheFallback:
|
||||||
|
await gen.aclose()
|
||||||
|
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||||
|
if first is None:
|
||||||
|
await gen.aclose()
|
||||||
|
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
|
||||||
|
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
yield first
|
||||||
|
async for chunk in gen:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_artifact_download_url(url: str) -> str:
|
||||||
|
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
|
||||||
|
headers = {**_headers(), "Accept": "application/vnd.github+json"}
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
resp = await client.get(url, headers=headers)
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
|
||||||
|
if resp.status_code != 302:
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
|
||||||
|
location = resp.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_artifact_and_stream(
|
||||||
|
url: str,
|
||||||
|
os_name: str,
|
||||||
|
artifact_id: int,
|
||||||
|
) -> StreamingResponse | FileResponse:
|
||||||
|
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
|
||||||
|
if stored_id == str(artifact_id) and cache_path.exists():
|
||||||
|
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_url = await _resolve_artifact_download_url(url)
|
||||||
|
except HTTPException:
|
||||||
|
if cache_path.exists():
|
||||||
|
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
|
||||||
|
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
|
||||||
|
|
||||||
|
async def stream_and_save():
|
||||||
|
total = 0
|
||||||
|
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", download_url) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="GitHub returned an error while downloading artifact file",
|
||||||
|
)
|
||||||
|
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
tmp_path.rename(cache_path)
|
||||||
|
etag_path.write_text(str(artifact_id))
|
||||||
|
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
|
||||||
|
except httpx.StreamClosed:
|
||||||
|
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except HTTPException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
gen = stream_and_save()
|
||||||
|
try:
|
||||||
|
first = await gen.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
first = None
|
||||||
|
except HTTPException:
|
||||||
|
if cache_path.exists():
|
||||||
|
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
await gen.aclose()
|
||||||
|
raise HTTPException(status_code=502, detail="Empty response from download")
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
yield first
|
||||||
|
async for chunk in gen:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _head_response(filename: str, content_length: int | None = None) -> Response:
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
|
||||||
|
async def download(request: Request, os_name: str):
|
||||||
|
"""Download app for the given OS: windows, linux, macos, android, ios."""
|
||||||
|
is_head = request.method == "HEAD"
|
||||||
|
os_name = os_name.lower()
|
||||||
|
logger.info("[download] %s /download/%s", request.method, os_name)
|
||||||
|
|
||||||
|
if os_name not in ("windows", "linux", "macos", "android", "ios"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os_name in ARTIFACT_NAMES:
|
||||||
|
try:
|
||||||
|
url, artifact_id = await asyncio.wait_for(
|
||||||
|
_fetch_pc_artifact_url(os_name),
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("[download] PC %s: GitHub API timeout", os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
if cache_path.exists():
|
||||||
|
if is_head:
|
||||||
|
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
result = await _download_artifact_and_stream(url, os_name, artifact_id)
|
||||||
|
if is_head:
|
||||||
|
fn = getattr(result, "filename", None) or cache_path.name
|
||||||
|
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||||
|
return _head_response(fn, size)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
stored_etag = None
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
if etag_path.exists():
|
||||||
|
stored_etag = etag_path.read_text().strip() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = await asyncio.wait_for(
|
||||||
|
_fetch_mobile_asset_url(os_name),
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
|
||||||
|
if cache_path.exists():
|
||||||
|
if is_head:
|
||||||
|
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
|
||||||
|
result = await _download_and_stream(url, os_name, stored_etag)
|
||||||
|
if is_head:
|
||||||
|
fn = getattr(result, "filename", None) or cache_path.name
|
||||||
|
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||||
|
return _head_response(fn, size)
|
||||||
|
return result
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail=exc.detail)
|
||||||
|
if exc.status_code in (502, 503, 504):
|
||||||
|
raise HTTPException(status_code=503, detail=exc.detail)
|
||||||
|
raise
|
||||||
@@ -24,12 +24,15 @@ from push_service import push_service
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
from pydantic import BaseModel
|
||||||
from better_profanity import profanity as _bp
|
from better_profanity import profanity as _bp
|
||||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||||
from security.profanity import contains_profanity
|
from security.profanity import contains_profanity
|
||||||
from security.rate_limit import rate_limit_per_ip
|
from security.rate_limit import rate_limit_per_ip
|
||||||
from websocket.utils import authenticate_user
|
from websocket.utils import authenticate_user
|
||||||
|
|
||||||
|
from models import FcmToken
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@@ -50,8 +53,8 @@ _BURST_COUNT_THRESHOLD = 20
|
|||||||
_SHORT_MESSAGE_LENGTH = 8
|
_SHORT_MESSAGE_LENGTH = 8
|
||||||
_SHORT_MESSAGE_REPEAT_LIMIT = 4
|
_SHORT_MESSAGE_REPEAT_LIMIT = 4
|
||||||
|
|
||||||
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
|
_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id)
|
||||||
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque)
|
_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id)
|
||||||
_burst_last_logged: dict[int, float] = {}
|
_burst_last_logged: dict[int, float] = {}
|
||||||
|
|
||||||
|
|
||||||
@@ -62,12 +65,23 @@ def _normalize_for_spam(text: str) -> str:
|
|||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None:
|
def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
def suspend(reason: str, event: str, **extra: Any) -> None:
|
def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None:
|
||||||
if user.suspended or user.id == 1:
|
if user.suspended or user.id == 1:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Delete spam messages that triggered the ban
|
||||||
|
if message_ids_to_delete:
|
||||||
|
try:
|
||||||
|
deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Deleted {deleted_count} spam messages for user {user.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete spam messages: {e}")
|
||||||
|
db.rollback()
|
||||||
|
|
||||||
user.suspended = True
|
user.suspended = True
|
||||||
user.suspension_reason = reason
|
user.suspension_reason = reason
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -77,6 +91,7 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
username=user.username,
|
username=user.username,
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
|
||||||
**extra,
|
**extra,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -86,8 +101,8 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
|
|
||||||
# Rate tracking for burst detection
|
# Rate tracking for burst detection
|
||||||
rate_bucket = _message_rate_cache[user.id]
|
rate_bucket = _message_rate_cache[user.id]
|
||||||
rate_bucket.append(now)
|
rate_bucket.append((now, message_id))
|
||||||
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS:
|
||||||
rate_bucket.popleft()
|
rate_bucket.popleft()
|
||||||
|
|
||||||
burst_count = len(rate_bucket)
|
burst_count = len(rate_bucket)
|
||||||
@@ -103,12 +118,17 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
window_seconds=_BURST_WINDOW_SECONDS,
|
window_seconds=_BURST_WINDOW_SECONDS,
|
||||||
)
|
)
|
||||||
_burst_last_logged[user.id] = now
|
_burst_last_logged[user.id] = now
|
||||||
|
|
||||||
|
# Get all message IDs from the burst window
|
||||||
|
burst_message_ids = [msg_id for _, msg_id in rate_bucket]
|
||||||
suspend(
|
suspend(
|
||||||
"Automatic suspension: excessive message rate",
|
"Automatic suspension: excessive message rate",
|
||||||
"auto_suspension_public_burst",
|
"auto_suspension_public_burst",
|
||||||
|
message_ids_to_delete=burst_message_ids,
|
||||||
count=burst_count,
|
count=burst_count,
|
||||||
window_seconds=_BURST_WINDOW_SECONDS,
|
window_seconds=_BURST_WINDOW_SECONDS,
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Similarity-based spam detection
|
# Similarity-based spam detection
|
||||||
normalized = _normalize_for_spam(content)
|
normalized = _normalize_for_spam(content)
|
||||||
@@ -116,21 +136,25 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
|
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
|
||||||
history.popleft()
|
history.popleft()
|
||||||
|
|
||||||
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized)
|
prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized)
|
||||||
prior_similar = sum(
|
prior_similar = sum(
|
||||||
1
|
1
|
||||||
for prev_norm, _, _ in history
|
for prev_norm, _, _, _ in history
|
||||||
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
|
||||||
)
|
)
|
||||||
|
|
||||||
history.append((normalized, content, now))
|
history.append((normalized, content, now, message_id))
|
||||||
|
|
||||||
total_matches = prior_same + prior_similar + 1
|
total_matches = prior_same + prior_similar + 1
|
||||||
|
|
||||||
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
|
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
|
||||||
|
# Get message IDs of all matching short messages
|
||||||
|
spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized]
|
||||||
|
spam_message_ids.append(message_id) # Include current message
|
||||||
suspend(
|
suspend(
|
||||||
"Automatic suspension: repeated short messages",
|
"Automatic suspension: repeated short messages",
|
||||||
"auto_suspension_public_spam",
|
"auto_suspension_public_spam",
|
||||||
|
message_ids_to_delete=spam_message_ids,
|
||||||
occurrences=prior_same + 1,
|
occurrences=prior_same + 1,
|
||||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||||
match_type="short",
|
match_type="short",
|
||||||
@@ -138,9 +162,20 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
|||||||
return
|
return
|
||||||
|
|
||||||
if total_matches >= _SPAM_MESSAGE_LIMIT:
|
if total_matches >= _SPAM_MESSAGE_LIMIT:
|
||||||
|
# Get message IDs of all matching similar messages
|
||||||
|
spam_message_ids = []
|
||||||
|
for prev_norm, _, _, msg_id in history:
|
||||||
|
if prev_norm == normalized:
|
||||||
|
spam_message_ids.append(msg_id)
|
||||||
|
elif prev_norm and normalized and prev_norm != normalized:
|
||||||
|
similarity = SequenceMatcher(None, normalized, prev_norm).ratio()
|
||||||
|
if similarity >= _SPAM_SIMILARITY_THRESHOLD:
|
||||||
|
spam_message_ids.append(msg_id)
|
||||||
|
spam_message_ids.append(message_id) # Include current message
|
||||||
suspend(
|
suspend(
|
||||||
"Automatic suspension: repeated similar public messages",
|
"Automatic suspension: repeated similar public messages",
|
||||||
"auto_suspension_public_spam",
|
"auto_suspension_public_spam",
|
||||||
|
message_ids_to_delete=spam_message_ids,
|
||||||
similar_messages=total_matches,
|
similar_messages=total_matches,
|
||||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||||
match_type="similar",
|
match_type="similar",
|
||||||
@@ -371,7 +406,7 @@ async def _send_message_internal(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
_monitor_public_message_activity(current_user, raw_content, db)
|
_monitor_public_message_activity(current_user, raw_content, new_message.id, db)
|
||||||
|
|
||||||
message_payload = convert_message(new_message)
|
message_payload = convert_message(new_message)
|
||||||
|
|
||||||
@@ -420,6 +455,97 @@ async def send_message(
|
|||||||
return await _send_message_internal(message_request, current_user, db, files)
|
return await _send_message_internal(message_request, current_user, db, files)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterFcmRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push/register")
|
||||||
|
async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Register or update an FCM token for the authenticated user.
|
||||||
|
"""
|
||||||
|
token = body.token.strip() if body and body.token else None
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# If token already exists (from another device), reassign it to this user.
|
||||||
|
token_row = db.query(FcmToken).filter(FcmToken.token == token).first()
|
||||||
|
if token_row:
|
||||||
|
token_row.user_id = current_user.id
|
||||||
|
else:
|
||||||
|
# Create new token record (allow multiple tokens per user)
|
||||||
|
new = FcmToken(user_id=current_user.id, token=token)
|
||||||
|
db.add(new)
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Registered FCM token for user {current_user.id}: {token}")
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to save token")
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push/unregister")
|
||||||
|
async def unregister_fcm_token(request: Request, body: RegisterFcmRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Unregister an FCM token. If `body.token` provided, remove only that token for the user.
|
||||||
|
If no token provided, remove all tokens for the user.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if body and body.token:
|
||||||
|
db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == body.token.strip()).delete()
|
||||||
|
else:
|
||||||
|
db.query(FcmToken).filter(FcmToken.user_id == current_user.id).delete()
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to remove token")
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/push/test")
|
||||||
|
async def push_test(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Send a test push to the current user's registered FCM token (for manual testing).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == current_user.id).all()
|
||||||
|
if not fcm_rows:
|
||||||
|
raise HTTPException(status_code=404, detail="No FCM token registered for user")
|
||||||
|
|
||||||
|
title = "FromChat test"
|
||||||
|
body = "This is a test push from the server"
|
||||||
|
data = {"type": "test", "timestamp": datetime.utcnow().isoformat()}
|
||||||
|
|
||||||
|
# Use push_service which uses Admin SDK internally; attempt to send to all tokens
|
||||||
|
failures = []
|
||||||
|
for fcm in fcm_rows:
|
||||||
|
try:
|
||||||
|
push_service._send_fcm_to_token(fcm.token, title, body, data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send test push to user {current_user.id} token {fcm.token}: {e}")
|
||||||
|
failures.append(str(e))
|
||||||
|
|
||||||
|
if failures and len(failures) == len(fcm_rows):
|
||||||
|
# All failed
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to send push to any token: {failures}")
|
||||||
|
|
||||||
|
return {"status": "success", "sent": len(fcm_rows) - len(failures), "failed": len(failures)}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"push_test error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Internal error")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/get_messages")
|
@router.get("/get_messages")
|
||||||
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
|
||||||
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
@@ -435,6 +561,43 @@ async def get_messages(request: Request, current_user: User = Depends(get_curren
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadRequest(BaseModel):
|
||||||
|
messageIds: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messages/new")
|
||||||
|
@rate_limit_per_ip("60/minute")
|
||||||
|
async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return unread public messages (Message.is_read == False).
|
||||||
|
"""
|
||||||
|
new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all()
|
||||||
|
messages_data = [convert_message(msg) for msg in new_messages]
|
||||||
|
return {"status": "success", "messages": messages_data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages/read")
|
||||||
|
@rate_limit_per_ip("60/minute")
|
||||||
|
async def mark_messages_read(request: Request, read_request: MarkReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Mark specified message IDs as read (set Message.is_read = True).
|
||||||
|
"""
|
||||||
|
if not read_request or not isinstance(read_request.messageIds, list) or len(read_request.messageIds) == 0:
|
||||||
|
return {"status": "success", "updated": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
updated_count = db.query(Message).filter(Message.id.in_(read_request.messageIds)).update({Message.is_read: True}, synchronize_session=False)
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to mark messages as read")
|
||||||
|
|
||||||
|
return {"status": "success", "updated": int(updated_count)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dm/send")
|
@router.post("/dm/send")
|
||||||
@rate_limit_per_ip("20/minute")
|
@rate_limit_per_ip("20/minute")
|
||||||
async def dm_send(
|
async def dm_send(
|
||||||
@@ -678,15 +841,16 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/edit_message/{message_id}")
|
async def _edit_message_internal(
|
||||||
@rate_limit_per_ip("20/minute")
|
|
||||||
async def edit_message(
|
|
||||||
request: Request,
|
|
||||||
message_id: int,
|
message_id: int,
|
||||||
edit_request: EditMessageRequest,
|
edit_request: EditMessageRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User,
|
||||||
db: Session = Depends(get_db)
|
db: Session
|
||||||
):
|
) -> dict:
|
||||||
|
"""Internal function to edit a message without requiring a Request object.
|
||||||
|
|
||||||
|
This can be called from both HTTP endpoints and WebSocket handlers.
|
||||||
|
"""
|
||||||
message = db.query(Message).filter(Message.id == message_id).first()
|
message = db.query(Message).filter(Message.id == message_id).first()
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
@@ -735,6 +899,18 @@ async def edit_message(
|
|||||||
return {"status": "success", "message": payload}
|
return {"status": "success", "message": payload}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/edit_message/{message_id}")
|
||||||
|
@rate_limit_per_ip("20/minute")
|
||||||
|
async def edit_message(
|
||||||
|
request: Request,
|
||||||
|
message_id: int,
|
||||||
|
edit_request: EditMessageRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return await _edit_message_internal(message_id, edit_request, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/delete_message/{message_id}")
|
@router.delete("/delete_message/{message_id}")
|
||||||
async def delete_message(
|
async def delete_message(
|
||||||
message_id: int,
|
message_id: int,
|
||||||
@@ -1004,6 +1180,7 @@ class MessaggingSocketManager:
|
|||||||
|
|
||||||
# Skip if this exact update was recently added
|
# Skip if this exact update was recently added
|
||||||
if signature in self.recent_updates[websocket]:
|
if signature in self.recent_updates[websocket]:
|
||||||
|
logger.warning(f"Update was skipped due to duplicate signature {signature}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Add to pending updates and track signature
|
# Add to pending updates and track signature
|
||||||
@@ -1011,10 +1188,8 @@ class MessaggingSocketManager:
|
|||||||
self.recent_updates[websocket].add(signature)
|
self.recent_updates[websocket].add(signature)
|
||||||
|
|
||||||
# Limit recent updates cache size (keep last 100 signatures per websocket)
|
# Limit recent updates cache size (keep last 100 signatures per websocket)
|
||||||
if len(self.recent_updates[websocket]) > 100:
|
if len(self.recent_updates[websocket]) > 1:
|
||||||
# Remove oldest entries (simple FIFO by converting to list and keeping last 100)
|
self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1])
|
||||||
# Actually, we'll just clear and rebuild on next flush - simpler approach
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
|
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
|
||||||
"""Flush pending updates for a WebSocket connection"""
|
"""Flush pending updates for a WebSocket connection"""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ _CUSTOM_RU_TERMS: Set[str] = {
|
|||||||
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
|
||||||
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
|
||||||
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
|
||||||
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор",
|
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
|
||||||
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
"пидоры", "пидорас", "пидорасы", "пидорасов",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,18 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
|||||||
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Patterns to check in original text (before normalization) to catch visual bypasses
|
||||||
|
# These patterns check for special character combinations that visually form letters
|
||||||
|
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
|
||||||
|
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
|
||||||
|
# This catches "хуесос" written as "}{¥€С0С" or variations
|
||||||
|
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
|
||||||
|
# The pattern allows any characters between to catch special chars like ¥€
|
||||||
|
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||||
|
# Also catch "}{" followed by "уесос" with 0 instead of о
|
||||||
|
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
|
||||||
|
)
|
||||||
|
|
||||||
# Map for normalizing homoglyphs (similar-looking characters)
|
# Map for normalizing homoglyphs (similar-looking characters)
|
||||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||||
# Also includes Greek, full-width, and other Unicode variants
|
# Also includes Greek, full-width, and other Unicode variants
|
||||||
@@ -182,6 +194,8 @@ _LEET_MAP = {
|
|||||||
"н": "н", # Already mapped, but explicit
|
"н": "н", # Already mapped, but explicit
|
||||||
# Special characters
|
# Special characters
|
||||||
"@": "а",
|
"@": "а",
|
||||||
|
# Multi-character visual bypasses (handled separately in preprocessing)
|
||||||
|
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
|
||||||
}
|
}
|
||||||
|
|
||||||
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
|
||||||
@@ -193,6 +207,18 @@ _SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
|||||||
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _preprocess_visual_bypasses(text: str) -> str:
|
||||||
|
"""
|
||||||
|
Preprocess text to convert multi-character visual bypasses to their intended letters.
|
||||||
|
This handles cases like "}{" visually forming "х".
|
||||||
|
"""
|
||||||
|
result = text
|
||||||
|
# Convert "}{" to "х" (visual bypass for Cyrillic х)
|
||||||
|
# The curly braces visually form the letter х when placed together
|
||||||
|
result = result.replace("}{", "х")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _normalize_char(ch: str) -> str:
|
def _normalize_char(ch: str) -> str:
|
||||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||||
# First try direct mapping (preserves case for non-mapped chars)
|
# First try direct mapping (preserves case for non-mapped chars)
|
||||||
@@ -257,7 +283,10 @@ def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False)
|
|||||||
(normalized_text, position_map) where position_map[i] is the original
|
(normalized_text, position_map) where position_map[i] is the original
|
||||||
position of the i-th character in normalized_text
|
position of the i-th character in normalized_text
|
||||||
"""
|
"""
|
||||||
# First normalize Unicode (composed vs decomposed)
|
# First preprocess visual bypasses (like "}{" -> "х")
|
||||||
|
text = _preprocess_visual_bypasses(text)
|
||||||
|
|
||||||
|
# Then normalize Unicode (composed vs decomposed)
|
||||||
normalized_unicode = unicodedata.normalize('NFKC', text)
|
normalized_unicode = unicodedata.normalize('NFKC', text)
|
||||||
|
|
||||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||||
@@ -316,17 +345,21 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
|
|||||||
start = pos + 1
|
start = pos + 1
|
||||||
|
|
||||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||||
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
|
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||||
# Only do subsequence matching for words of length 4 or more to avoid false positives
|
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||||
# Use stricter span limits for shorter words to prevent false matches in long legitimate words
|
|
||||||
if len(word_lower) >= 4:
|
|
||||||
word_chars = list(word_lower)
|
word_chars = list(word_lower)
|
||||||
text_chars = list(normalized_lower)
|
text_chars = list(normalized_lower)
|
||||||
# Stricter ratio for shorter words, more lenient for longer words
|
|
||||||
if len(word_lower) <= 5:
|
# Stricter span limits based on word length to prevent false positives
|
||||||
max_span_ratio = 1.5 # Very strict for short words
|
# Shorter words get much stricter limits
|
||||||
|
if len(word_lower) <= 3:
|
||||||
|
max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
|
||||||
|
elif len(word_lower) == 4:
|
||||||
|
max_span_ratio = 1.4 # Strict for 4-char words
|
||||||
|
elif len(word_lower) <= 5:
|
||||||
|
max_span_ratio = 1.5 # Moderate for 5-char words
|
||||||
else:
|
else:
|
||||||
max_span_ratio = 2.0 # Slightly more lenient for longer words
|
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||||
|
|
||||||
# Try to find the word as a subsequence
|
# Try to find the word as a subsequence
|
||||||
i = 0 # position in text
|
i = 0 # position in text
|
||||||
@@ -582,7 +615,13 @@ def contains_profanity(text: str) -> bool:
|
|||||||
|
|
||||||
_rebuild_dictionary()
|
_rebuild_dictionary()
|
||||||
|
|
||||||
# Check phrase patterns first
|
# Check original text patterns first (before normalization) to catch visual bypasses
|
||||||
|
# like "}{" used to form "х"
|
||||||
|
for pattern in _ORIGINAL_TEXT_PATTERNS:
|
||||||
|
if pattern.search(text):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check phrase patterns
|
||||||
if _check_phrase_patterns(text):
|
if _check_phrase_patterns(text):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import HTTPException, WebSocket
|
from fastapi import HTTPException, WebSocket, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from websocket.registry import WebSocketHandlerRegistry
|
from websocket.registry import WebSocketHandlerRegistry
|
||||||
from routes.messaging import (
|
from routes.messaging import (
|
||||||
MessaggingSocketManager,
|
MessaggingSocketManager,
|
||||||
_send_message_internal,
|
_send_message_internal,
|
||||||
|
_edit_message_internal,
|
||||||
get_messages,
|
get_messages,
|
||||||
edit_message,
|
edit_message,
|
||||||
delete_message,
|
delete_message,
|
||||||
@@ -211,14 +212,11 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
|
|||||||
@websocket_handler("editMessage", authRequired=True)
|
@websocket_handler("editMessage", authRequired=True)
|
||||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||||
"""Edit a public chat message."""
|
"""Edit a public chat message."""
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
message_id = data["message_id"]
|
message_id = data["message_id"]
|
||||||
request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
|
||||||
|
|
||||||
# Create a dummy request object for the HTTP endpoint function
|
response = await _edit_message_internal(message_id, edit_request, user, db)
|
||||||
dummy_request = SimpleNamespace()
|
|
||||||
response = await edit_message(dummy_request, message_id, request, user, db)
|
|
||||||
await manager.broadcast({
|
await manager.broadcast({
|
||||||
"type": "messageEdited",
|
"type": "messageEdited",
|
||||||
"data": response["message"]
|
"data": response["message"]
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
fromchat.ru {
|
||||||
|
reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 {
|
||||||
|
lb_policy first
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||||
|
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||||
|
X-Frame-Options "DENY" # Prevent clickjacking
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rate_limit {
|
||||||
|
zone global {
|
||||||
|
key {remote_ip}
|
||||||
|
window 1m
|
||||||
|
burst 20
|
||||||
|
events 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@errors {
|
||||||
|
expression {err.status_code} >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @errors {
|
||||||
|
rewrite * /{err.status_code}
|
||||||
|
reverse_proxy https://http.cat {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
replace_status {err.status_code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beta.fromchat.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8301 {
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||||
|
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||||
|
X-Frame-Options "DENY" # Prevent clickjacking
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
|
||||||
|
}
|
||||||
|
|
||||||
|
rate_limit {
|
||||||
|
zone global {
|
||||||
|
key {remote_ip}
|
||||||
|
window 1m
|
||||||
|
burst 20
|
||||||
|
events 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_errors {
|
||||||
|
@errors {
|
||||||
|
expression {err.status_code} >= 400
|
||||||
|
}
|
||||||
|
|
||||||
|
handle @errors {
|
||||||
|
rewrite * /{err.status_code}
|
||||||
|
reverse_proxy https://http.cat {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
replace_status {err.status_code}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
git.fromchat.ru {
|
||||||
|
reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 {
|
||||||
|
lb_policy first
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
|
||||||
|
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
|
||||||
|
X-Frame-Options "DENY" # Prevent clickjacking
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
}
|
||||||
|
|
||||||
|
rate_limit {
|
||||||
|
zone global {
|
||||||
|
key {remote_ip}
|
||||||
|
window 1m
|
||||||
|
burst 20
|
||||||
|
events 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8400
|
||||||
|
}
|
||||||
|
|
||||||
|
getgadgets.toolbox-io.ru {
|
||||||
|
reverse_proxy 95.165.0.162:8401
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#
|
||||||
|
# Custom Caddy built with:
|
||||||
|
# - Rate limit plugin
|
||||||
|
#
|
||||||
|
|
||||||
|
FROM caddy:2-builder AS builder
|
||||||
|
RUN xcaddy build \
|
||||||
|
--with github.com/mholt/caddy-ratelimit
|
||||||
|
|
||||||
|
FROM caddy:2
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
|
||||||
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
|
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||||
volumes:
|
volumes:
|
||||||
- data:/app/data
|
- data:/app/data
|
||||||
- logs:/app/logs
|
- logs:/app/logs
|
||||||
@@ -41,8 +42,26 @@ services:
|
|||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: package.json
|
path: package.json
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
build:
|
||||||
|
context: ./caddy
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- certs:/root/site/certs
|
||||||
|
environment:
|
||||||
|
XDG_DATA_HOME: /root/site/certs
|
||||||
|
XDG_CONFIG_HOME: /root/site/certs
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
name: fromchat-data
|
name: fromchat-data
|
||||||
logs:
|
logs:
|
||||||
name: fromchat-logs
|
name: fromchat-logs
|
||||||
|
certs:
|
||||||
|
name: fromchat-certs
|
||||||
@@ -3,11 +3,13 @@ FROM node:24 AS frontend
|
|||||||
|
|
||||||
# 1.1. Install npm dependencies
|
# 1.1. Install npm dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Copy package.json and workspace package directory first (needed for workspace resolution)
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
|
COPY frontend/packages/ frontend/packages/
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
npm install --ignore-scripts
|
npm install --ignore-scripts
|
||||||
|
|
||||||
# 1.2. Build
|
# 1.2. Copy remaining frontend code and build
|
||||||
COPY frontend frontend
|
COPY frontend frontend
|
||||||
RUN npm run frontend:build
|
RUN npm run frontend:build
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
src/
|
||||||
|
tsconfig.json
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Publishing FromChat Protocol
|
||||||
|
|
||||||
|
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
|
||||||
|
2. **GitHub account**: For GitHub Packages
|
||||||
|
3. **Node.js**: Version 18 or higher
|
||||||
|
|
||||||
|
## Publishing to npm
|
||||||
|
|
||||||
|
### Important: Scoped Package Setup
|
||||||
|
|
||||||
|
The package uses the `@fromchat` scope. You have two options:
|
||||||
|
|
||||||
|
**Option A: Create an npm organization (Recommended)**
|
||||||
|
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
|
||||||
|
2. Create an organization named `fromchat`
|
||||||
|
3. Add yourself as a member
|
||||||
|
4. Then proceed with publishing below
|
||||||
|
|
||||||
|
**Option B: Use unscoped package name**
|
||||||
|
If you prefer not to create an organization, change the package name in `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "fromchat-protocol" // Remove the @fromchat/ scope
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
|
||||||
|
|
||||||
|
### 1. Build the package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/packages/fromchat-protocol
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This compiles TypeScript to JavaScript in the `dist/` directory.
|
||||||
|
|
||||||
|
### 2. Login to npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm login
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter your npm username, password, and email.
|
||||||
|
|
||||||
|
### 3. Publish
|
||||||
|
|
||||||
|
**If using scoped package (`@fromchat/protocol`):**
|
||||||
|
```bash
|
||||||
|
npm publish --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
**If using unscoped package (`fromchat-protocol`):**
|
||||||
|
```bash
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--access public` flag is required for scoped packages (packages starting with `@`).
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
|
||||||
|
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
|
||||||
|
|
||||||
|
### 5. Update version for future releases
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch version (1.0.0 -> 1.0.1)
|
||||||
|
npm version patch
|
||||||
|
|
||||||
|
# Minor version (1.0.0 -> 1.1.0)
|
||||||
|
npm version minor
|
||||||
|
|
||||||
|
# Major version (1.0.0 -> 2.0.0)
|
||||||
|
npm version major
|
||||||
|
|
||||||
|
# Then publish
|
||||||
|
npm publish --access public
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing to GitHub Packages
|
||||||
|
|
||||||
|
### 1. Create a GitHub Personal Access Token
|
||||||
|
|
||||||
|
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
|
||||||
|
2. Generate a new token with `write:packages` and `read:packages` permissions
|
||||||
|
3. Save the token securely
|
||||||
|
|
||||||
|
### 2. Configure npm to use GitHub Packages
|
||||||
|
|
||||||
|
Create or edit `~/.npmrc`:
|
||||||
|
|
||||||
|
```
|
||||||
|
@fromchat:registry=https://npm.pkg.github.com
|
||||||
|
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://npm.pkg.github.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update package.json
|
||||||
|
|
||||||
|
Update the repository URL to match your GitHub repository:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
|
||||||
|
"directory": "frontend/packages/fromchat-protocol"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Build and publish
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend/packages/fromchat-protocol
|
||||||
|
npm run build
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Install from GitHub Packages
|
||||||
|
|
||||||
|
Users can install your package with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add to `.npmrc`:
|
||||||
|
|
||||||
|
```
|
||||||
|
@fromchat:registry=https://npm.pkg.github.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the Published Package
|
||||||
|
|
||||||
|
### From npm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @fromchat/protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FromChatProtocol } from "@fromchat/protocol";
|
||||||
|
```
|
||||||
|
|
||||||
|
### From GitHub Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @fromchat/protocol@npm:@fromchat/protocol
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The package is built to `dist/` directory
|
||||||
|
- Source files in `src/` are excluded from the published package
|
||||||
|
- Only `dist/` and `README.md` are included in the published package
|
||||||
|
- The package uses ES modules (ESM) format
|
||||||
|
- TypeScript definitions are included in `dist/`
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# FromChat Protocol
|
||||||
|
|
||||||
|
Simple ECDH-based encryption protocol for direct messages.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The FromChat Protocol provides end-to-end encryption for direct messages using:
|
||||||
|
- **X25519** (ECDH) for key exchange
|
||||||
|
- **HKDF** for key derivation
|
||||||
|
- **AES-GCM** for symmetric encryption
|
||||||
|
|
||||||
|
This module is completely independent and can be used in any JavaScript/TypeScript project.
|
||||||
|
|
||||||
|
## Protocol Flow
|
||||||
|
|
||||||
|
### Encryption
|
||||||
|
|
||||||
|
1. Generate a random message key (mk) - 32 bytes
|
||||||
|
2. Generate a random salt (wkSalt) - 16 bytes
|
||||||
|
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
|
||||||
|
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
|
||||||
|
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
|
||||||
|
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
|
||||||
|
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
|
||||||
|
|
||||||
|
### Decryption
|
||||||
|
|
||||||
|
1. Derive shared secret from ECDH
|
||||||
|
2. Derive wrapping key from shared secret using salt from message
|
||||||
|
3. Decrypt wrappedMk to get mk
|
||||||
|
4. Decrypt ciphertext with mk
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { FromChatProtocol } from "@fromchat/protocol";
|
||||||
|
|
||||||
|
// Initialize with your private key
|
||||||
|
const protocol = new FromChatProtocol(privateKey);
|
||||||
|
|
||||||
|
// Encrypt a message
|
||||||
|
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
|
||||||
|
|
||||||
|
// Decrypt a message
|
||||||
|
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `FromChatProtocol`
|
||||||
|
|
||||||
|
#### Constructor
|
||||||
|
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
|
||||||
|
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EncryptedMessage {
|
||||||
|
iv: string; // Base64 encoded IV for message encryption
|
||||||
|
ciphertext: string; // Base64 encoded encrypted message
|
||||||
|
salt: string; // Base64 encoded salt for wrapping key derivation
|
||||||
|
iv2: string; // Base64 encoded IV for message key wrapping
|
||||||
|
wrappedMk: string; // Base64 encoded wrapped message key
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup & Key Management
|
||||||
|
|
||||||
|
The protocol also includes utilities for backing up and restoring private keys:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
encryptBackupWithPassword,
|
||||||
|
decryptBackupWithPassword,
|
||||||
|
encodeBlob,
|
||||||
|
decodeBlob
|
||||||
|
} from "@fromchat/protocol";
|
||||||
|
|
||||||
|
// Create a backup of a private key
|
||||||
|
const bundle = { version: 1, privateKey: myPrivateKey };
|
||||||
|
const encrypted = await encryptBackupWithPassword("my-password", bundle);
|
||||||
|
const backupString = encodeBlob(encrypted); // Store this string
|
||||||
|
|
||||||
|
// Restore from backup
|
||||||
|
const encryptedBlob = decodeBlob(backupString);
|
||||||
|
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Each message uses a fresh random message key
|
||||||
|
- The protocol does not provide forward secrecy
|
||||||
|
- Keys are derived using HKDF with SHA-256
|
||||||
|
- All encryption uses AES-GCM with 12-byte IVs
|
||||||
|
- Backup encryption uses PBKDF2 with 210,000 iterations
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@fromchat/protocol",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"encryption",
|
||||||
|
"ecdh",
|
||||||
|
"e2ee",
|
||||||
|
"end-to-end-encryption",
|
||||||
|
"x25519",
|
||||||
|
"aes-gcm",
|
||||||
|
"hkdf"
|
||||||
|
],
|
||||||
|
"author": "denis0001-dev",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Toolbox-io/FromChat.git",
|
||||||
|
"directory": "frontend/packages/fromchat-protocol"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/Toolbox-io/FromChat/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric";
|
import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
|
||||||
import { importPassword, deriveKEK, randomBytes } from "./kdf";
|
|
||||||
|
|
||||||
export interface PrivateKeyBundle {
|
export interface PrivateKeyBundle {
|
||||||
version: 1;
|
version: 1;
|
||||||
@@ -65,4 +64,3 @@ export function decodeBlob(json: string): EncryptedBackupBlob {
|
|||||||
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
|
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -6,18 +6,16 @@ export interface X25519KeyPair {
|
|||||||
privateKey: Uint8Array;
|
privateKey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KeyPair = X25519KeyPair;
|
|
||||||
|
|
||||||
export function generateX25519KeyPair(): X25519KeyPair {
|
export function generateX25519KeyPair(): X25519KeyPair {
|
||||||
const kp = nacl.box.keyPair();
|
const kp = nacl.box.keyPair();
|
||||||
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
|
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
|
||||||
// nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF.
|
|
||||||
return nacl.box.before(theirPublicKey, myPrivateKey);
|
return nacl.box.before(theirPublicKey, myPrivateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
||||||
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
|
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Re-export all crypto functions for convenience
|
||||||
|
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric";
|
||||||
|
export type { X25519KeyPair } from "./asymmetric";
|
||||||
|
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric";
|
||||||
|
export type { AesGcmCiphertext } from "./symmetric";
|
||||||
|
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf";
|
||||||
|
|
||||||
@@ -1,19 +1,3 @@
|
|||||||
export async function importPassword(password: string): Promise<CryptoKey> {
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
|
||||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
|
||||||
return crypto.subtle.deriveKey(
|
|
||||||
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
|
||||||
passwordKey,
|
|
||||||
{ name: "AES-GCM", length: 256 },
|
|
||||||
false,
|
|
||||||
["encrypt", "decrypt"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
|
export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise<Uint8Array> {
|
||||||
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
|
const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial;
|
||||||
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||||
@@ -29,3 +13,19 @@ export function randomBytes(length: number): Uint8Array {
|
|||||||
crypto.getRandomValues(out);
|
crypto.getRandomValues(out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function importPassword(password: string): Promise<CryptoKey> {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | ArrayBuffer, iterations = 210_000): Promise<CryptoKey> {
|
||||||
|
const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{ name: "PBKDF2", salt: saltBuffer, iterations, hash: "SHA-256" },
|
||||||
|
passwordKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,10 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
|
||||||
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
|
|
||||||
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
||||||
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
||||||
: (iv as ArrayBuffer);
|
: (iv as ArrayBuffer);
|
||||||
|
|
||||||
// Normalize ciphertext to a contiguous ArrayBuffer slice
|
|
||||||
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
||||||
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
||||||
: (ciphertext as ArrayBuffer);
|
: (ciphertext as ArrayBuffer);
|
||||||
@@ -26,7 +24,6 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
|
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
|
||||||
// Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues
|
|
||||||
const keyBuffer = rawKey instanceof Uint8Array
|
const keyBuffer = rawKey instanceof Uint8Array
|
||||||
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
|
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
|
||||||
: (rawKey as ArrayBuffer);
|
: (rawKey as ArrayBuffer);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export { FromChatProtocol } from "./protocol/FromChatProtocol";
|
||||||
|
export type { EncryptedMessage } from "./protocol/types";
|
||||||
|
|
||||||
|
// Export crypto functions
|
||||||
|
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./crypto/asymmetric";
|
||||||
|
export type { X25519KeyPair } from "./crypto/asymmetric";
|
||||||
|
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./crypto/symmetric";
|
||||||
|
export type { AesGcmCiphertext } from "./crypto/symmetric";
|
||||||
|
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./crypto/kdf";
|
||||||
|
|
||||||
|
// Export backup functions
|
||||||
|
export {
|
||||||
|
encryptBackupWithPassword,
|
||||||
|
decryptBackupWithPassword,
|
||||||
|
encodeBlob,
|
||||||
|
decodeBlob,
|
||||||
|
serializeBundle,
|
||||||
|
deserializeBundle
|
||||||
|
} from "./backup/backup";
|
||||||
|
export type { PrivateKeyBundle, EncryptedBackupBlob } from "./backup/backup";
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric";
|
||||||
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric";
|
||||||
|
import { randomBytes } from "../crypto/kdf";
|
||||||
|
import type { EncryptedMessage } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FromChat Protocol - Simple ECDH-based encryption
|
||||||
|
*
|
||||||
|
* Protocol:
|
||||||
|
* 1. Generate random message key (mk) - 32 bytes
|
||||||
|
* 2. Generate random salt (wkSalt) - 16 bytes
|
||||||
|
* 3. Derive shared secret from ECDH (X25519)
|
||||||
|
* 4. Derive wrapping key from shared secret using HKDF with salt
|
||||||
|
* 5. Encrypt message with mk using AES-GCM
|
||||||
|
* 6. Encrypt (wrap) mk with wrapping key using AES-GCM
|
||||||
|
* 7. Send: { iv, ciphertext, salt, iv2, wrappedMk }
|
||||||
|
*/
|
||||||
|
export class FromChatProtocol {
|
||||||
|
private privateKey: Uint8Array;
|
||||||
|
|
||||||
|
constructor(privateKey: Uint8Array) {
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a message for a recipient
|
||||||
|
* @param recipientPublicKey - Recipient's X25519 public key
|
||||||
|
* @param plaintext - Message to encrypt
|
||||||
|
* @returns Encrypted message with all necessary fields
|
||||||
|
*/
|
||||||
|
async encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage> {
|
||||||
|
// Generate random message key
|
||||||
|
const mk = randomBytes(32);
|
||||||
|
|
||||||
|
// Generate random salt for wrapping key derivation
|
||||||
|
const wkSalt = randomBytes(16);
|
||||||
|
|
||||||
|
// Derive shared secret from ECDH
|
||||||
|
const shared = ecdhSharedSecret(this.privateKey, recipientPublicKey);
|
||||||
|
|
||||||
|
// Derive wrapping key from shared secret using HKDF
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
|
// Encrypt the message with message key
|
||||||
|
const plaintextBytes = new TextEncoder().encode(plaintext);
|
||||||
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), plaintextBytes);
|
||||||
|
|
||||||
|
// Encrypt (wrap) the message key with wrapping key
|
||||||
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
|
|
||||||
|
// Convert to base64 for transmission
|
||||||
|
return {
|
||||||
|
iv: btoa(String.fromCharCode(...encMsg.iv)),
|
||||||
|
ciphertext: btoa(String.fromCharCode(...encMsg.ciphertext)),
|
||||||
|
salt: btoa(String.fromCharCode(...wkSalt)),
|
||||||
|
iv2: btoa(String.fromCharCode(...wrap.iv)),
|
||||||
|
wrappedMk: btoa(String.fromCharCode(...wrap.ciphertext))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a message from a sender
|
||||||
|
* @param senderPublicKey - Sender's X25519 public key
|
||||||
|
* @param message - Encrypted message
|
||||||
|
* @returns Decrypted plaintext
|
||||||
|
*/
|
||||||
|
async decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string> {
|
||||||
|
// Decode base64 fields
|
||||||
|
const salt = new Uint8Array(
|
||||||
|
atob(message.salt).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
const iv2 = new Uint8Array(
|
||||||
|
atob(message.iv2).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
const wrappedMk = new Uint8Array(
|
||||||
|
atob(message.wrappedMk).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
const iv = new Uint8Array(
|
||||||
|
atob(message.iv).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
atob(message.ciphertext).split("").map(c => c.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive shared secret from ECDH
|
||||||
|
const shared = ecdhSharedSecret(this.privateKey, senderPublicKey);
|
||||||
|
|
||||||
|
// Derive wrapping key from shared secret using salt from message
|
||||||
|
const wkRaw = await deriveWrappingKey(shared, salt, new Uint8Array([1]));
|
||||||
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
|
// Decrypt (unwrap) the message key
|
||||||
|
const mk = await aesGcmDecrypt(wk, iv2, wrappedMk);
|
||||||
|
|
||||||
|
// Decrypt the message with message key
|
||||||
|
const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Encrypted message format
|
||||||
|
*/
|
||||||
|
export interface EncryptedMessage {
|
||||||
|
iv: string; // Base64 encoded IV for message encryption
|
||||||
|
ciphertext: string; // Base64 encoded encrypted message
|
||||||
|
salt: string; // Base64 encoded salt for wrapping key derivation
|
||||||
|
iv2: string; // Base64 encoded IV for message key wrapping
|
||||||
|
wrappedMk: string; // Base64 encoded wrapped message key
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
|
||||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||||
import type { Headers } from "@/core/types";
|
import type { Headers } from "@/core/types";
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "../user/auth";
|
import { getAuthHeaders } from "../user/auth";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { getCurrentKeys } from "../user/auth";
|
import { getCurrentKeys } from "../user/auth";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { fetchUserPublicKey } from "../crypto/identity";
|
import { fetchUserPublicKey } from "../crypto/identity";
|
||||||
import { fetchUsers, searchUsers } from "../user/search";
|
import { fetchUsers, searchUsers } from "../user/search";
|
||||||
|
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
|
||||||
|
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
|
||||||
|
|
||||||
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
|
||||||
const keys = getCurrentKeys();
|
const protocol = getOrInitProtocol();
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
const senderPublicKey = ub64(senderPublicKeyB64);
|
||||||
|
|
||||||
// Obtain the key
|
return await protocol.decryptMessage(senderPublicKey, envelope);
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
|
|
||||||
return new TextDecoder().decode(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
|
||||||
@@ -39,27 +30,14 @@ export async function fetchMessages(userId: number, token: string, limit: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const protocol = getOrInitProtocol();
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||||
|
|
||||||
// Encryption key
|
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
|
|
||||||
// Encrypt the message
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
const payload: SendDMRequest = {
|
const payload: SendDMRequest = {
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
...encrypted
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
salt: b64(wkSalt),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext)
|
|
||||||
};
|
};
|
||||||
if (replyToId) payload.replyToId = replyToId;
|
if (replyToId) payload.replyToId = replyToId;
|
||||||
|
|
||||||
@@ -74,15 +52,16 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
|
||||||
|
// For files, we need to use the same message key for both the message and files
|
||||||
|
// So we'll do the encryption manually here to reuse the mk
|
||||||
const keys = getCurrentKeys();
|
const keys = getCurrentKeys();
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
if (!keys) throw new Error("Keys not initialized");
|
||||||
|
|
||||||
const mk = randomBytes(32);
|
const mk = randomBytes(32);
|
||||||
const wkSalt = randomBytes(16);
|
const wkSalt = randomBytes(16);
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
const wk = await importAesGcmKey(wkRaw);
|
||||||
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
const wrap = await aesGcmEncrypt(wk, mk);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
@@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
|
|||||||
const data = new Uint8Array(await f.arrayBuffer());
|
const data = new Uint8Array(await f.arrayBuffer());
|
||||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||||
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
|
||||||
const serverName = f.name; // server uses provided name
|
const serverName = f.name;
|
||||||
names.push(serverName);
|
names.push(serverName);
|
||||||
form.append("files", new File([blob], serverName));
|
form.append("files", new File([blob], serverName));
|
||||||
}
|
}
|
||||||
form.append("fileNames", JSON.stringify(names));
|
form.append("fileNames", JSON.stringify(names));
|
||||||
|
|
||||||
// Merge files metadata into plaintext JSON and encrypt
|
// Encrypt the plaintext JSON with the same mk
|
||||||
let obj: DmEncryptedJSON;
|
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
||||||
try {
|
|
||||||
obj = JSON.parse(plaintextJson);
|
|
||||||
} catch {
|
|
||||||
obj = { type: "text", data: { content: String(plaintextJson) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
|
|
||||||
form.append("dm_payload", JSON.stringify({
|
form.append("dm_payload", JSON.stringify({
|
||||||
recipientId: recipientId,
|
recipientId: recipientId,
|
||||||
iv: b64(encMsg.iv),
|
iv: b64(encMsg.iv),
|
||||||
@@ -128,28 +100,17 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
|
||||||
const keys = getCurrentKeys();
|
const protocol = getOrInitProtocol();
|
||||||
if (!keys) throw new Error("Keys not initialized");
|
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||||
|
|
||||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
||||||
const mk = randomBytes(32);
|
|
||||||
const wkSalt = randomBytes(16);
|
|
||||||
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
|
|
||||||
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
|
|
||||||
const wk = await importAesGcmKey(wkRaw);
|
|
||||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
|
|
||||||
const wrap = await aesGcmEncrypt(wk, mk);
|
|
||||||
|
|
||||||
await request({
|
await request({
|
||||||
type: "dmEdit",
|
type: "dmEdit",
|
||||||
credentials: { scheme: "Bearer", credentials: authToken },
|
credentials: { scheme: "Bearer", credentials: authToken },
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
iv: b64(encMsg.iv),
|
...encrypted
|
||||||
ciphertext: b64(encMsg.ciphertext),
|
|
||||||
iv2: b64(wrap.iv),
|
|
||||||
wrappedMk: b64(wrap.ciphertext),
|
|
||||||
salt: b64(wkSalt)
|
|
||||||
}
|
}
|
||||||
} as DMEditRequest);
|
} as DMEditRequest);
|
||||||
}
|
}
|
||||||
@@ -190,5 +151,3 @@ export async function markRead(id: number, authToken: string): Promise<void> {
|
|||||||
|
|
||||||
// Re-export user functions for convenience
|
// Re-export user functions for convenience
|
||||||
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
export { fetchUsers, searchUsers, fetchUserPublicKey };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "../user/auth";
|
|
||||||
import type { BackupBlob } from "@/core/types";
|
import type { BackupBlob } from "@/core/types";
|
||||||
|
import api from "@/core/api";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current user's backup blob
|
* Fetches the current user's backup blob
|
||||||
*/
|
*/
|
||||||
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||||
const headers = getAuthHeaders(token, true);
|
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers
|
headers
|
||||||
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
|
|||||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||||
const payload: BackupBlob = { blob: blobJson }
|
const payload: BackupBlob = { blob: blobJson }
|
||||||
|
|
||||||
const headers = getAuthHeaders(token, true);
|
const headers = api.user.auth.getAuthHeaders(token, true);
|
||||||
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers,
|
headers,
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./account";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { getCurrentKeys } from "./account";
|
import { getCurrentKeys } from "./account";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import { getAuthHeaders } from "./account";
|
import { getAuthHeaders } from "./account";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { getCurrentKeys } from "./account";
|
import { getCurrentKeys } from "./account";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { API_BASE_URL } from "@/core/config";
|
import { API_BASE_URL } from "@/core/config";
|
||||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
||||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
|
||||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
||||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
|
||||||
import { randomBytes } from "@/utils/crypto/kdf";
|
|
||||||
import { b64, ub64 } from "@/utils/utils";
|
import { b64, ub64 } from "@/utils/utils";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import api from "@/core/api";
|
|||||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
||||||
import { request } from "@/core/websocket";
|
import { request } from "@/core/websocket";
|
||||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
||||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
import { importAesGcmKey } from "@fromchat/protocol";
|
||||||
import E2EEWorker from "./e2eeWorker?worker";
|
import E2EEWorker from "./e2eeWorker?worker";
|
||||||
import { delay } from "@/utils/utils";
|
import { delay } from "@/utils/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
|
||||||
|
import useWindowSize from "@/core/hooks/useWindowSize";
|
||||||
|
import styles from "./css/split-button.module.scss";
|
||||||
|
|
||||||
|
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
|
||||||
|
|
||||||
|
interface SplitButtonProps {
|
||||||
|
text: ReactNode;
|
||||||
|
icon?: ReactNode | string;
|
||||||
|
menu: ReactNode;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onMenuOpen: (open: boolean) => void;
|
||||||
|
onPrimaryClick?: () => void;
|
||||||
|
variant?: SplitButtonVariant;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
menuAriaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitButton({
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
menu,
|
||||||
|
menuOpen: open,
|
||||||
|
onMenuOpen,
|
||||||
|
onPrimaryClick,
|
||||||
|
variant = "filled",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
menuAriaLabel,
|
||||||
|
}: SplitButtonProps) {
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
|
const primaryRipple = useRippleHandlers(disabled);
|
||||||
|
const menuRipple = useRippleHandlers(disabled);
|
||||||
|
|
||||||
|
const MENU_GAP = 16;
|
||||||
|
const EDGE_PAD = 16;
|
||||||
|
|
||||||
|
const updateMenuPosition = useCallback(() => {
|
||||||
|
const anchor = menuSegmentRef.current;
|
||||||
|
if (!anchor) return;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const menuEl = menuRef.current;
|
||||||
|
const menuWidth = menuEl?.offsetWidth ?? 220;
|
||||||
|
const menuHeight = menuEl?.offsetHeight ?? 320;
|
||||||
|
|
||||||
|
const anchorCenterX = rect.left + rect.width / 2;
|
||||||
|
let left: number;
|
||||||
|
let top: number | undefined;
|
||||||
|
let bottom: number | undefined;
|
||||||
|
let maxHeight: number;
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
|
||||||
|
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
|
||||||
|
const fitsBelow = menuHeight <= availableBelow;
|
||||||
|
const fitsAbove = menuHeight <= availableAbove;
|
||||||
|
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
|
||||||
|
|
||||||
|
if (placeAbove) {
|
||||||
|
bottom = vh - (rect.top - MENU_GAP);
|
||||||
|
maxHeight = Math.max(100, availableAbove);
|
||||||
|
} else {
|
||||||
|
top = rect.bottom + MENU_GAP;
|
||||||
|
maxHeight = Math.max(100, availableBelow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
|
||||||
|
left = EDGE_PAD;
|
||||||
|
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
|
||||||
|
left = vw - menuWidth - EDGE_PAD;
|
||||||
|
} else {
|
||||||
|
left = anchorCenterX - menuWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuPosition({ top, bottom, left, maxHeight });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
onMenuOpen(false);
|
||||||
|
setIsExiting(true);
|
||||||
|
}, [onMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && !isExiting) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
updateMenuPosition();
|
||||||
|
window.addEventListener("scroll", updateMenuPosition, true);
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (rootRef.current?.contains(target)) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setIsExiting(true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open && menuRef.current) {
|
||||||
|
updateMenuPosition();
|
||||||
|
}
|
||||||
|
}, [open, updateMenuPosition]);
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrimaryClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (open) closeMenu();
|
||||||
|
else onMenuOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClass =
|
||||||
|
variant === "tonal"
|
||||||
|
? styles.variantTonal
|
||||||
|
: variant === "outlined"
|
||||||
|
? styles.variantOutlined
|
||||||
|
: variant === "elevated"
|
||||||
|
? styles.variantElevated
|
||||||
|
: styles.variantFilled;
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof icon === "string") {
|
||||||
|
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={styles.leadingIconIcon}>{icon}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootClasses = [
|
||||||
|
styles.splitButton,
|
||||||
|
variantClass,
|
||||||
|
disabled ? styles.disabled : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={rootClasses}
|
||||||
|
data-open={open ? "true" : "false"}
|
||||||
|
aria-disabled={disabled ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primarySegment}
|
||||||
|
onClick={handlePrimaryClick}
|
||||||
|
onPointerDown={primaryRipple.onPointerDown}
|
||||||
|
onPointerEnter={primaryRipple.onPointerEnter}
|
||||||
|
onPointerLeave={primaryRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={primaryRipple.rippleRef} />
|
||||||
|
<span className={styles.primaryContent}>
|
||||||
|
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
|
||||||
|
<span className={styles.label}>{text}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={menuSegmentRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.menuSegment}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
onPointerDown={menuRipple.onPointerDown}
|
||||||
|
onPointerEnter={menuRipple.onPointerEnter}
|
||||||
|
onPointerLeave={menuRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={menuAriaLabel}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={menuRipple.rippleRef} />
|
||||||
|
<span className={styles.menuIcon}>
|
||||||
|
<MaterialIcon name="expand_more" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(open || isExiting) &&
|
||||||
|
menuPosition &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="menu"
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.menu}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
...(menuPosition.bottom != null
|
||||||
|
? { bottom: menuPosition.bottom }
|
||||||
|
: { top: menuPosition.top }),
|
||||||
|
left: menuPosition.left,
|
||||||
|
maxHeight: menuPosition.maxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{menu}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
@use "../../../css/material" as *;
|
||||||
|
|
||||||
|
$height: 40px;
|
||||||
|
$trailing-width: 48px; // 12 + 22 + 14 per spec
|
||||||
|
$between-space: 2px;
|
||||||
|
$outer-radius: calc(#{$height} / 2); // 20px
|
||||||
|
$inner-radius: 4px;
|
||||||
|
$inner-radius-hovered: 12px;
|
||||||
|
|
||||||
|
.splitButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $outer-radius;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment,
|
||||||
|
.menuSegment {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
min-height: $height;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $color-dark-primary;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdui-ripple {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment {
|
||||||
|
border-top-left-radius: $outer-radius;
|
||||||
|
border-bottom-left-radius: $outer-radius;
|
||||||
|
border-top-right-radius: $inner-radius;
|
||||||
|
border-bottom-right-radius: $inner-radius;
|
||||||
|
padding-inline: 16px 12px;
|
||||||
|
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.leadingIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.leadingIconIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuSegment {
|
||||||
|
width: $trailing-width;
|
||||||
|
border-top-right-radius: $outer-radius;
|
||||||
|
border-bottom-right-radius: $outer-radius;
|
||||||
|
border-top-left-radius: $inner-radius;
|
||||||
|
border-bottom-left-radius: $inner-radius;
|
||||||
|
margin-left: $between-space;
|
||||||
|
padding-inline: 12px 14px;
|
||||||
|
transition:
|
||||||
|
border-top-left-radius 0.18s ease-out,
|
||||||
|
border-bottom-left-radius 0.18s ease-out,
|
||||||
|
padding-inline 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
font-size: 22px;
|
||||||
|
transition: transform 0.18s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
mdui-icon {
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-open="true"] .menuSegment {
|
||||||
|
$size: calc($trailing-width / 2);
|
||||||
|
border-radius: $size;
|
||||||
|
padding-inline: 13px 13px;
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantFilled {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantTonal {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantOutlined {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.8);
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantElevated {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.3),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: $color-dark-surface-container-low;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$menu-padding: 8px;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding: $menu-padding;
|
||||||
|
min-width: 220px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgba($color-dark-surface-container-high, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 100000000;
|
||||||
|
|
||||||
|
// Custom slim semi-transparent scrollbar
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba($color-dark-on-surface, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba($color-dark-on-surface, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
|
||||||
|
|
||||||
|
mdui-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<line x1="1.407" y1="1.353" x2="1.407" y2="1.46" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0, 0, 0); fill-rule: nonzero; opacity: 1;"/>
|
||||||
|
<g transform="matrix(1.329817, 0, 0, 1.329817, -42.024433, -38.371166)" style="">
|
||||||
|
<g transform="matrix(1, 0, 0, 1, -0.000001, 0.000008)">
|
||||||
|
<path d="M 187.533 167.601 C 184.614 169.278 180.394 175.015 176.028 176.522 L 173.703 176.879 C 173.692 176.878 173.682 176.878 173.671 176.877 C 170.911 176.954 167.048 175.793 164.888 173.621 C 164.62 173.351 164.378 173.064 164.167 172.763 C 163.081 170.76 162.591 168.959 162.078 167.519 C 163.681 138.029 142.006 97.682 141.345 89.031 L 140.535 89.081 L 143.379 87.758 C 146.633 86.246 146.577 81.601 143.289 80.165 L 126.072 72.64 C 124.802 72.084 123.338 72.196 122.169 72.941 L 109.131 81.222 C 106.644 82.801 106.537 86.395 108.928 88.118 L 120.16 96.21 C 121.382 97.093 122.987 97.244 124.353 96.607 L 131.993 93.054 L 124.35 96.609 C 122.984 97.244 121.38 97.093 120.157 96.213 L 112.629 90.787 L 112.182 90.815 C 112.182 96.766 92.754 126.354 85.61 153.184 C 82.2 144.438 85.83 134.593 92.372 122.051 C 83.667 131.338 75.779 146.462 83.605 158.699 C 87.499 164.679 93.454 168.284 99.138 171.235 C 103.255 173.904 113.854 176.441 110.918 182.885 C 110.925 182.876 110.931 182.867 110.938 182.859 C 110.931 182.869 110.925 182.88 110.918 182.89 C 108.679 187.681 104.866 190.521 100.012 192.077 C 98.585 189.825 96.894 187.456 94.937 184.967 L 90.627 179.198 C 89.739 177.993 88.66 176.155 87.266 173.682 C 85.934 171.212 84.729 169.31 83.779 167.978 C 82.956 166.519 81.624 165.061 79.912 163.603 C 79.401 163.15 78.861 162.743 78.299 162.386 C 75.967 160.899 72.963 160.587 70.538 161.919 C 69.819 162.313 69.262 162.771 68.883 163.288 C 67.998 164.493 67.489 165.825 67.363 167.219 C 67.172 168.551 66.792 169.439 66.157 169.883 C 66.152 169.886 66.141 169.891 66.135 169.894 C 65.64 163.51 67.408 157.038 70.867 151.489 C 73.595 147.111 75.664 142.699 76.428 138.228 C 76.779 130.993 78.71 123.934 82.113 117.038 C 86.159 107.861 95.837 101.976 98.644 92.659 C 102.474 80.66 83.964 28.579 126.067 28.984 C 150.53 28.984 159.32 50.152 159.32 84.644 C 159.32 107.391 201.316 122.18 187.533 167.601 Z M 151.486 211.429 C 151.801 212.025 152.146 212.581 152.52 213.101 C 136.094 207.361 120.468 206.548 104.498 212.435 C 105.326 211.043 105.778 209.435 105.778 207.781 C 105.778 207.779 105.778 207.778 105.778 207.776 C 105.778 207.761 105.778 207.746 105.778 207.731 C 105.778 207.729 105.778 207.727 105.778 207.725 C 105.778 204.739 104.88 201.359 103.085 197.582 C 107.83 198.93 113.536 199.677 120.393 199.677 C 136.171 200.012 146.507 196.345 152.958 190.163 C 151.666 200.295 149.855 207.938 151.486 211.429 Z M 168.963 155.718 C 167.694 156.465 166.457 157.278 165.336 158.247 C 164.063 159.306 162.925 160.573 162.122 161.99 C 164.732 160.051 167.618 158.932 170.555 157.952 L 170.855 157.851 C 172.058 156.221 173.112 154.49 173.983 152.649 C 179.496 140.288 172.1 126.454 162.265 118.749 C 168.908 130.233 175.535 143.047 168.963 155.718 Z M 110.325 80.46 L 121.571 73.32 C 121.829 72.168 121.972 70.94 121.972 69.664 C 121.972 63.111 118.283 57.8 113.731 57.8 C 109.179 57.8 105.489 63.111 105.489 69.664 C 105.489 74.469 107.476 78.594 110.325 80.46 Z M 140.237 78.831 C 142.075 76.656 143.244 73.357 143.244 69.664 C 143.244 63.111 139.555 57.8 135.002 57.8 C 130.45 57.8 126.761 63.111 126.761 69.664 C 126.761 70.859 126.887 72.008 127.115 73.095 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.673 C 97.691 188.648 97.674 188.626 97.658 188.6 C 97.677 188.626 97.691 188.651 97.708 188.673 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 178.569 117.583 L 162.265 118.749 C 173.339 131.799 177.532 144.523 169.998 156.693 C 166.446 158.264 163.822 160.028 162.122 161.987 C 162.189 163.875 162.178 165.735 162.08 167.551 C 161.65 166.34 161.203 165.379 160.377 164.766 C 160.374 164.774 160.368 164.786 160.363 164.794 C 161.832 165.882 162.13 168.073 163.274 170.855 C 163.316 170.956 163.353 171.052 163.397 171.156 C 163.619 171.676 163.867 172.21 164.167 172.763 C 166.07 175.489 170.571 176.97 173.676 176.883 C 174.039 176.905 174.404 176.88 174.767 176.829 C 174.882 176.812 175 176.781 175.115 176.759 C 175.365 176.708 175.615 176.649 175.863 176.571 C 175.995 176.529 176.124 176.481 176.256 176.43 C 176.495 176.34 176.734 176.236 176.973 176.124 C 177.099 176.065 177.223 176.006 177.349 175.941 C 177.616 175.801 177.883 175.646 178.147 175.48 C 178.237 175.424 178.327 175.376 178.414 175.317 C 178.768 175.087 179.122 174.84 179.471 174.576 C 179.532 174.531 179.591 174.48 179.651 174.432 C 179.943 174.208 180.229 173.977 180.516 173.738 C 180.628 173.646 180.738 173.55 180.848 173.454 C 181.086 173.249 181.325 173.041 181.558 172.831 C 181.668 172.732 181.778 172.631 181.887 172.533 C 182.14 172.302 182.39 172.069 182.638 171.836 C 182.713 171.766 182.789 171.693 182.865 171.622 C 183.202 171.302 183.537 170.987 183.863 170.675 C 184.141 170.411 184.413 170.153 184.68 169.903 C 184.773 169.818 184.86 169.737 184.95 169.655 C 185.155 169.467 185.358 169.282 185.557 169.107 C 185.658 169.017 185.759 168.933 185.861 168.849 C 186.043 168.694 186.223 168.545 186.397 168.408 C 186.496 168.329 186.594 168.253 186.69 168.18 C 186.869 168.045 187.046 167.924 187.218 167.812 C 187.297 167.761 187.378 167.705 187.454 167.66 C 187.482 167.643 187.513 167.621 187.538 167.604 C 194.434 144.871 187.358 129.81 178.569 117.583 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.676 C 97.708 188.676 97.708 188.676 97.708 188.676 C 97.711 188.679 97.711 188.682 97.714 188.685 C 97.714 188.685 97.714 188.685 97.714 188.685 C 98.54 189.845 99.296 190.975 99.995 192.079 C 104.857 190.525 108.676 187.684 110.918 182.888 C 119.916 168.72 59.028 169.675 92.372 122.051 L 80.221 121.306 C 79.123 124.043 78.257 126.806 77.622 129.593 C 77.611 129.638 77.6 129.683 77.589 129.728 C 77.299 131.021 77.069 132.322 76.883 133.628 C 76.849 133.864 76.821 134.103 76.79 134.339 C 76.625 135.629 76.493 136.924 76.431 138.228 C 75.666 142.699 73.598 147.111 70.87 151.489 C 70.46 152.149 70.075 152.823 69.715 153.506 C 69.414 154.08 69.136 154.661 68.872 155.248 C 68.827 155.344 68.776 155.44 68.734 155.535 C 68.127 156.915 67.616 158.328 67.208 159.767 C 66.273 163.066 65.876 166.488 66.141 169.894 C 66.146 169.891 66.157 169.886 66.163 169.883 C 66.798 169.439 67.177 168.551 67.369 167.219 C 67.495 165.825 68.004 164.493 68.889 163.288 C 69.268 162.771 69.824 162.313 70.544 161.919 C 72.969 160.587 75.973 160.899 78.305 162.386 C 78.867 162.743 79.407 163.15 79.918 163.603 C 81.629 165.061 82.961 166.519 83.785 167.978 C 84.734 169.31 85.94 171.212 87.272 173.682 C 88.666 176.155 89.745 177.993 90.633 179.198 L 94.943 184.967 C 95.688 185.914 96.373 186.836 97.037 187.746 C 97.261 188.058 97.492 188.37 97.708 188.676 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 122.169 72.941 C 118.834 75.093 115.509 77.26 112.241 79.513 L 109.8 81.219 C 107.29 82.543 107.108 86.283 109.592 87.601 C 110.213 88.014 120.685 94.772 120.823 94.921 C 121.686 95.556 122.672 95.629 123.597 95.235 C 123.597 95.235 128.913 92.698 128.913 92.698 C 132.521 91.046 136.14 89.416 139.799 87.879 C 140.951 87.263 143.674 86.679 144.295 85.44 C 145.102 84.229 144.885 82.292 143.618 81.301 C 140.94 79.665 136.031 77.493 133.153 75.984 C 133.153 75.984 127.767 73.449 127.767 73.449 C 126.033 72.567 123.979 71.716 122.169 72.941 Z M 122.169 72.941 C 123.948 71.676 126.072 72.486 127.817 73.337 C 127.817 73.337 133.339 75.568 133.339 75.568 C 136.525 76.889 141.575 78.485 144.585 79.985 C 147.94 82.014 147.237 87.637 143.626 89.039 C 139.26 91.341 134.738 93.535 130.282 95.634 C 127.727 96.584 123.675 99.481 120.826 98.191 C 118.682 97.396 110.668 90.321 108.647 88.91 C 105.613 86.881 106.132 81.958 109.417 80.618 C 109.417 80.618 112 79.134 112 79.134 C 115.428 77.13 118.803 75.043 122.169 72.941 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 137.309 83.658 C 129.815 90.793 116.833 92.487 108.451 85.83 C 118.092 89.511 128.081 88.126 137.309 83.658 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 135.177 76.619 C 136.199 75.787 136.995 74.354 137.234 72.634 C 137.638 69.737 136.337 67.158 134.322 66.877 C 132.31 66.596 130.349 68.717 129.942 71.614 C 129.801 72.612 129.877 73.562 130.102 74.402 L 135.177 76.619 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 115.226 77.35 L 118.477 75.284 C 118.94 74.267 119.14 72.98 118.949 71.612 C 118.544 68.715 116.583 66.593 114.568 66.874 C 112.556 67.155 111.252 69.735 111.657 72.632 C 112.008 75.138 113.523 77.055 115.226 77.35 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 59.565 208.06 C 67.843 208.624 75.959 211.078 83.813 213.494 C 87.536 214.444 91.254 216.223 95.12 215.916 C 110.114 213.418 101.209 197.528 95.48 190.025 C 91.948 185.386 87.606 179.844 84.661 174.59 C 82.773 171.676 80.913 167.472 77.864 165.353 C 76.043 163.476 71.974 162.855 70.552 165.308 C 69.26 167.528 70.055 170.085 67.534 171.853 C 66.008 172.746 64.775 172.898 63.246 173.241 C 63.03 173.227 61.69 173.283 61.453 173.275 C 58.157 173.502 53.65 172.238 52.563 176.27 C 51.548 180.165 53.532 184.234 53.389 188.345 C 53.512 192.045 51.677 195.729 49.702 198.539 C 48.876 199.939 48.05 201.462 47.923 202.836 C 47.923 202.836 47.895 202.625 47.895 202.625 C 48.39 204.522 51 205.533 52.799 206.213 C 54.976 206.989 57.264 207.565 59.565 208.043 C 57.219 207.877 54.858 207.683 52.526 207.127 C 50.104 206.433 47.33 205.812 46.257 203.049 C 46.176 198.978 48.98 195.946 49.725 192.984 C 51.425 187.937 48.719 183.45 48.387 178.296 C 48.379 169.56 54.704 168.554 61.099 168.652 C 61.099 168.652 62.988 168.554 62.988 168.554 L 62.49 168.607 C 63.215 168.411 64.415 168.155 64.924 167.826 C 64.362 168.315 64.946 167.716 64.949 166.739 C 65.267 162.259 69.116 158.446 73.638 158.494 C 76.42 158.37 79.069 159.514 81.166 161.259 C 84.762 163.752 87.005 168.211 89.362 171.945 C 94.294 181.041 102.184 188.401 106.051 198.258 C 110.586 208.352 107.428 218.445 95.457 219.623 C 91.046 219.825 86.937 217.515 82.888 216.127 C 75.248 212.887 67.68 209.875 59.565 208.06 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 173.676 176.883 C 170.04 177.085 166.101 176.093 163.628 173.137 C 162.616 171.504 161.905 169.658 161.242 167.975 C 160.756 166.418 159.579 165.201 158.46 165.297 C 154.203 166.106 156.179 175.222 155.81 178.734 C 155.754 184.413 155.22 190.07 154.523 195.628 C 154.133 200.068 152.424 206.837 153.947 210.561 C 155.895 214.048 159.787 216.161 163.743 215.908 C 166.519 215.635 168.883 213.823 171.541 212.008 C 178.285 206.981 184.38 201.231 191.84 196.904 C 194.948 195.108 198.368 193.566 201.861 192.501 C 203.075 192.231 204.952 190.952 205.103 190.143 C 205.053 189.705 204.471 189.193 204.145 188.879 C 201.195 186.423 195.49 184.627 193.473 182.365 C 190.983 180.314 189.73 176.486 189.986 173.477 C 189.927 170.948 189.772 167.112 188.134 168.394 C 183.601 170.858 179.6 177.209 173.676 176.883 C 176.63 176.759 178.805 174.629 180.834 172.746 C 183.436 170.431 189.337 161.785 191.947 168.818 C 193.237 172.586 192.152 177.414 195.718 179.766 C 196.949 180.974 200.987 182.446 202.777 183.326 C 209.203 185.979 212.499 191.309 205.674 195.839 C 201.861 197.685 197.972 198.767 194.414 201.184 C 187.519 205.522 181.325 211.283 174.736 216.332 C 171.822 218.443 168.059 221.036 163.892 221.219 C 157.221 221.539 149.907 217.024 148.541 210.28 C 147.889 205.092 149.28 199.972 149.974 194.959 C 150.904 189.491 151.747 184.065 152.079 178.594 C 152.748 173.682 151.132 163.049 158.578 162.858 C 163.322 163.372 162.987 169.245 164.743 172.446 C 166.393 174.955 170.386 176.7 173.676 176.883 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 879 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200px" height="200px" viewBox="0 0 200 200">
|
||||||
|
<path d="M 176.226 68.183 C 175.066 69.083 154.581 80.625 154.581 106.291 C 154.581 135.977 180.646 146.48 181.427 146.739 C 181.307 147.38 177.286 161.122 167.684 175.125 C 159.122 187.448 150.18 199.75 136.577 199.75 C 122.974 199.75 119.474 191.849 103.771 191.849 C 88.467 191.849 83.027 200.011 70.583 200.011 C 58.141 200.011 49.46 188.608 39.478 174.605 C 27.915 158.162 18.573 132.617 18.573 108.372 C 18.573 69.484 43.858 48.859 68.744 48.859 C 81.966 48.859 92.988 57.541 101.29 57.541 C 109.191 57.541 121.514 48.339 136.557 48.339 C 142.259 48.339 162.743 48.859 176.226 68.183 Z M 129.416 31.876 C 135.637 24.494 140.038 14.252 140.038 4.01 C 140.038 2.59 139.918 1.149 139.658 -0.011 C 129.536 0.369 117.493 6.73 110.232 15.152 C 104.531 21.634 99.209 31.876 99.209 42.257 C 99.209 43.818 99.47 45.379 99.59 45.879 C 100.23 45.998 101.27 46.139 102.31 46.139 C 111.393 46.139 122.815 40.057 129.416 31.876 Z" style="fill: rgb(255, 255, 255);" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42">
|
||||||
|
<path fill="#FFFFFF" fill-rule="evenodd" d="M21.47 41.88c-4.11 0-6.02-.6-9.34-3-2.1 2.7-8.75 4.81-9.04 1.2 0-2.71-.6-5-1.28-7.5C1 29.5.08 26.07.08 21.1.08 9.23 9.82.3 21.36.3c11.55 0 20.6 9.37 20.6 20.91a20.6 20.6 0 0 1-20.49 20.67Zm.17-31.32c-5.62-.29-10 3.6-10.97 9.7-.8 5.05.62 11.2 1.83 11.52.58.14 2.04-1.04 2.95-1.95a10.4 10.4 0 0 0 5.08 1.81 10.7 10.7 0 0 0 11.19-9.97 10.7 10.7 0 0 0-10.08-11.1Z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200px" height="200px">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M 13.693 88.614 C 67.369 65.304 103.102 49.815 121.044 42.299 C 172.114 20.984 182.849 17.303 189.751 17.149 C 191.284 17.149 194.658 17.456 196.958 19.295 C 198.799 20.829 199.259 22.823 199.565 24.356 C 199.873 25.889 200.18 29.111 199.873 31.565 C 197.112 60.703 185.15 131.402 179.016 163.916 C 176.408 177.717 171.347 182.317 166.441 182.778 C 155.705 183.699 147.577 175.724 137.302 168.976 C 121.044 158.395 111.997 151.8 96.202 141.371 C 77.951 129.408 89.76 122.815 100.188 112.08 C 102.948 109.319 150.031 66.378 150.95 62.545 C 151.104 62.084 151.104 60.243 150.031 59.323 C 148.956 58.403 147.423 58.71 146.197 59.016 C 144.509 59.323 118.745 76.5 68.596 110.391 C 61.235 115.453 54.64 117.906 48.659 117.754 C 42.065 117.6 29.49 114.072 19.981 111.005 C 8.479 107.325 -0.723 105.331 0.045 98.89 C 0.503 95.516 5.105 92.142 13.693 88.614 Z" style="fill: rgb(255, 255, 255);"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="0" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
@@ -1,5 +1,5 @@
|
|||||||
import { AuthContainer } from "./Auth";
|
import { AuthContainer } from "./Auth";
|
||||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
@@ -8,18 +8,23 @@ import { RegisterForm } from "./RegisterForm";
|
|||||||
import type { Variants, Transition } from "motion/react";
|
import type { Variants, Transition } from "motion/react";
|
||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 400;
|
||||||
|
|
||||||
const slideVariants: Variants = {
|
const slideVariants: Variants = {
|
||||||
enter: (direction: number) => ({
|
enter: (direction: number) => ({
|
||||||
x: direction > 0 ? 300 : -300,
|
x: direction > 0 ? 300 : -300,
|
||||||
opacity: 0
|
opacity: 0,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
}),
|
}),
|
||||||
center: {
|
center: {
|
||||||
x: 0,
|
x: 0,
|
||||||
opacity: 1
|
opacity: 1,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
},
|
},
|
||||||
exit: (direction: number) => ({
|
exit: (direction: number) => ({
|
||||||
x: direction > 0 ? -300 : 300,
|
x: direction > 0 ? -300 : 300,
|
||||||
opacity: 0
|
opacity: 0,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,80 +50,55 @@ export default function AuthPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const loginFormRef = useRef<HTMLDivElement>(null);
|
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||||
const registerFormRef = useRef<HTMLDivElement>(null);
|
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
|
const [containerHeight, setContainerHeight] = useState<number>(400);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const currentMode = searchParams.get("mode") || "login";
|
const currentMode = searchParams.get("mode") || "login";
|
||||||
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
|
||||||
const [effectActivated, setEffectActivated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevMode.current !== currentMode) {
|
if (prevMode.current !== currentMode) {
|
||||||
setDirection(currentMode === "register" ? 1 : -1);
|
setDirection(currentMode === "register" ? 1 : -1);
|
||||||
prevMode.current = currentMode;
|
prevMode.current = currentMode;
|
||||||
enteringElementRef.current = currentMode as "login" | "register";
|
setIsTransitioning(true);
|
||||||
}
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
const measureActiveHeight = useCallback(() => {
|
// Setup ResizeObserver to watch for content changes
|
||||||
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
|
useEffect(() => {
|
||||||
if (activeComponent) {
|
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
|
||||||
const height = activeComponent.scrollHeight;
|
|
||||||
|
if (activeRef.current) {
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const height = entry.contentRect.height;
|
||||||
if (height > 0) {
|
if (height > 0) {
|
||||||
setContainerHeight(height);
|
setContainerHeight(Math.max(height, MIN_HEIGHT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentMode, loginFormRef, registerFormRef]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!effectActivated) {
|
|
||||||
setEffectActivated(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always measure, but prioritize the entering element during transitions
|
|
||||||
// Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
|
|
||||||
let rafId2: number | null = null;
|
|
||||||
const rafId1 = requestAnimationFrame(() => {
|
|
||||||
rafId2 = requestAnimationFrame(() => {
|
|
||||||
measureActiveHeight();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(activeRef.current);
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
const initialHeight = activeRef.current.scrollHeight;
|
||||||
|
if (initialHeight > 0) {
|
||||||
|
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafId1);
|
resizeObserver.disconnect();
|
||||||
if (rafId2 !== null) {
|
|
||||||
cancelAnimationFrame(rafId2);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
|
|
||||||
function switchMode(newMode: "login" | "register") {
|
function switchMode(newMode: "login" | "register") {
|
||||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAnimationComplete(
|
function handleAnimationComplete() {
|
||||||
currentMode: "login" | "register",
|
setIsTransitioning(false);
|
||||||
mode: "login" | "register",
|
}
|
||||||
enteringElementRef: RefObject<"login" | "register" | null>,
|
|
||||||
formRef: React.RefObject<HTMLDivElement | null>,
|
|
||||||
setContainerHeight: (height: number) => void
|
|
||||||
) {
|
|
||||||
return () => {
|
|
||||||
if (currentMode === mode && enteringElementRef.current === mode) {
|
|
||||||
enteringElementRef.current = null;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (formRef.current && currentMode === mode) {
|
|
||||||
const height = formRef.current.scrollHeight;
|
|
||||||
if (height > 0) {
|
|
||||||
setContainerHeight(height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContainer>
|
<AuthContainer>
|
||||||
@@ -127,14 +107,8 @@ export default function AuthPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
height: `${containerHeight}px`,
|
||||||
transition: "height 0.3s ease"
|
transition: isTransitioning ? "height 0.3s ease" : "none"
|
||||||
}}
|
|
||||||
onAnimationStart={() => {
|
|
||||||
|
|
||||||
}}
|
|
||||||
onAnimationEnd={() => {
|
|
||||||
setContainerHeight("auto");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="sync" custom={direction}>
|
<AnimatePresence mode="sync" custom={direction}>
|
||||||
@@ -148,11 +122,8 @@ export default function AuthPage() {
|
|||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={slideTransition}
|
transition={slideTransition}
|
||||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
style={{
|
|
||||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -166,7 +137,7 @@ export default function AuthPage() {
|
|||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={slideTransition}
|
transition={slideTransition}
|
||||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
>
|
>
|
||||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||||
|
|||||||
@@ -98,6 +98,12 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
&.relative {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.authHeader {
|
.authHeader {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import { parse } from "marked";
|
|||||||
import { escape as escapeHtml } from "he";
|
import { escape as escapeHtml } from "he";
|
||||||
import { useEffect, useState, useRef, useMemo } from "react";
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import api from "@/core/api";
|
import api from "@/core/api";
|
||||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
|
||||||
import { useUserStore } from "@/state/user";
|
import { useUserStore } from "@/state/user";
|
||||||
import { useProfileStore } from "@/state/profile";
|
import { useProfileStore } from "@/state/profile";
|
||||||
import { StatusBadge } from "@/core/components/StatusBadge";
|
import { StatusBadge } from "@/core/components/StatusBadge";
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { MaterialButton } from "@/utils/material";
|
import { MaterialIcon } from "@/utils/material";
|
||||||
import styles from "./download-app.module.scss";
|
import styles from "./download-app.module.scss";
|
||||||
|
|
||||||
export default function DownloadAppPage() {
|
export default function DownloadAppPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.downloadAppScreen}>
|
<div className={styles.downloadAppScreen}>
|
||||||
<div>
|
<div className={styles.downloadAppCard}>
|
||||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
<h1>Скачайте приложение</h1>
|
||||||
<p>
|
<p>
|
||||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
Этот сайт не предназначен для работы на маленьких экранах.
|
||||||
вам нужно скачать приложение мессенджера.
|
Выберите вашу платформу:
|
||||||
</p>
|
</p>
|
||||||
|
<div className={styles.downloadAppButtons}>
|
||||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
<a href="/download?os=android" className={styles.downloadAppBtn}>
|
||||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
<MaterialIcon name="android" />
|
||||||
|
Android
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/download?os=ios" className={styles.downloadAppBtn}>
|
||||||
<p>
|
<MaterialIcon name="phone_iphone" />
|
||||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
iOS
|
||||||
</p>
|
|
||||||
|
|
||||||
<a href="https://t.me/denis0001-dev">
|
|
||||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,70 @@
|
|||||||
|
@use "../../css/material" as *;
|
||||||
|
|
||||||
.downloadAppScreen {
|
.downloadAppScreen {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 100vw;
|
min-width: 100vw;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
|
background-color: $color-dark-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppCard {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: rgba($color-dark-surface-container, 0.6);
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-dark-primary;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-primary, 0.2);
|
||||||
|
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($color-dark-primary, 0.3);
|
||||||
|
border-color: $color-dark-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||||
|
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||||
|
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
|
||||||
|
import styles from "@/pages/home/download-dialog.module.scss";
|
||||||
|
|
||||||
|
interface DownloadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
os: DownloadOs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AndroidInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на Android</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Вы скачали APK-файл FromChat. Чтобы установить приложение:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>Откройте загруженный APK-файл из шторки уведомлений или файлового менеджера.</li>
|
||||||
|
<li>
|
||||||
|
Если появится запрос "Разрешить установку из неизвестных источников" — дайте
|
||||||
|
разрешение для браузера, из которого вы скачивали APK.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
|
||||||
|
нажмите "Подробнее" → "Всё равно установить" (или аналогичную кнопку).
|
||||||
|
</li>
|
||||||
|
<li>Дождитесь завершения установки и откройте FromChat из списка приложений.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IosInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на iOS</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone
|
||||||
|
или iPad, потребуется один из вариантов сторонней установки:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>TrollStore</strong>: постоянная установка приложений из IPA-файлов. Требуется поддерживаемая
|
||||||
|
версия iOS и настройка TrollStore на устройстве.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Джейлбрейк</strong>: установка через менеджер пакетов (Sileo, Cydia и т.п.) или напрямую
|
||||||
|
из файлового менеджера, если у вас уже есть джейлбрейк.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Другие сервисы сайдлоада</strong>: сторонние инструменты, которые подписывают IPA-файл
|
||||||
|
вашим сертификатом разработчика или временным сертификатом.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className={styles.text}>
|
||||||
|
К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет — именно поэтому я
|
||||||
|
бы сам iPhone не покупал 😄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstructions(os: DownloadOs): ReactNode {
|
||||||
|
if (os === "android") {
|
||||||
|
return <AndroidInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (os === "ios") {
|
||||||
|
return <IosInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) {
|
||||||
|
const osInfo = OS_CONFIG[os];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
className={styles.downloadDialog}
|
||||||
|
contentClassName={styles.downloadDialogContent}
|
||||||
|
afterChildren={
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<MaterialButton variant="filled" onClick={() => onOpenChange(false)}>
|
||||||
|
Закрыть
|
||||||
|
</MaterialButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<MaterialIcon name="download" className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h2 className={styles.title}>Спасибо за скачивание!</h2>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
FromChat для
|
||||||
|
<span className={styles.osName}>{osInfo.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderInstructions(os)}
|
||||||
|
</div>
|
||||||
|
</StyledDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { MaterialIcon } from "@/utils/material";
|
||||||
|
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-footer.module.scss";
|
||||||
|
|
||||||
|
interface HomeFooterProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
|
||||||
|
return (
|
||||||
|
<footer className={styles.homepageFooter}>
|
||||||
|
<div className={styles.footerBrand}>
|
||||||
|
<div className={styles.footerLogoRow}>
|
||||||
|
<div className={styles.footerLogo} />
|
||||||
|
<span className={styles.footerBrandName}>FromChat</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.footerCopyright}>FromChat © 2026</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerLinks}>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onScrollToDownload}
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="download" className={styles.footerLinkIcon} />
|
||||||
|
Скачать приложение
|
||||||
|
</button>
|
||||||
|
<Link to="/login" className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="language" className={styles.footerLinkIcon} />
|
||||||
|
Веб-версия
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
|
||||||
|
ПК-клиент
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_APP}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="android" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код приложения
|
||||||
|
</a>
|
||||||
|
<GitHubLink className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="code" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код веб-версии
|
||||||
|
</GitHubLink>
|
||||||
|
<a
|
||||||
|
href={GITHUB_LICENSE}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="description" className={styles.footerLinkIcon} />
|
||||||
|
Лицензия
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href="https://t.me/fromchat_ch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
|
||||||
|
/>
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
|
||||||
|
/>
|
||||||
|
MAX
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
|
import { MaterialButton, MaterialIconButton } from "@/utils/material";
|
||||||
|
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-header.module.scss";
|
||||||
|
|
||||||
|
interface HomeHeaderProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeHeader({ onScrollToDownload }: HomeHeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const { isMobile } = useDownloadAppScreen();
|
||||||
|
const isLoggedIn = user.authToken && user.currentUser;
|
||||||
|
|
||||||
|
const handleMobileDownload = () => {
|
||||||
|
onScrollToDownload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleGetStarted() {
|
||||||
|
if (isMobile) {
|
||||||
|
handleMobileDownload();
|
||||||
|
} else if (isLoggedIn) {
|
||||||
|
navigate("/chat");
|
||||||
|
} else {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBtn = (
|
||||||
|
<MaterialButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleGetStarted}
|
||||||
|
icon={
|
||||||
|
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
|
||||||
|
}
|
||||||
|
className={styles.headerDownloadButton}
|
||||||
|
>
|
||||||
|
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
|
||||||
|
</MaterialButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.homepageHeader} data-home-header>
|
||||||
|
<div className={styles.headerInner}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<div className={styles.logoIcon} />
|
||||||
|
<h1>FromChat</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerCenterLinks}>
|
||||||
|
<GitHubLink>
|
||||||
|
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
|
||||||
|
</GitHubLink>
|
||||||
|
<SupportLink>
|
||||||
|
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
|
||||||
|
</SupportLink>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerButton}>
|
||||||
|
{openBtn}
|
||||||
|
{isMobile ? (
|
||||||
|
<MaterialIconButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleMobileDownload}
|
||||||
|
icon="download"
|
||||||
|
className={styles.headerSmallButton}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,228 +1,236 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useUserStore } from "@/state/user";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import styles from "./home.module.scss";
|
import styles from "@/pages/home/home.module.scss";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||||
|
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
|
||||||
|
import dmScreenshot from "@/images/screenshots/dm.png";
|
||||||
|
import windowsIcon from "@/images/windows.svg";
|
||||||
|
import linuxIcon from "@/images/linux.svg";
|
||||||
|
import macIcon from "@/images/mac.svg";
|
||||||
|
import { HomeHeader } from "@/pages/home/HomeHeader";
|
||||||
|
import { HomeFooter } from "@/pages/home/HomeFooter";
|
||||||
|
import { SplitButton } from "@/core/components/SplitButton";
|
||||||
|
import { DownloadDialog } from "@/pages/home/DownloadDialog";
|
||||||
|
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
|
||||||
|
|
||||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
interface FeatureSectionProps {
|
||||||
return (
|
title: ReactNode;
|
||||||
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
|
children: ReactNode;
|
||||||
);
|
screenshot: string;
|
||||||
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SupportLink({ children }: { children: React.ReactNode }) {
|
function FeatureSection({
|
||||||
return (
|
title,
|
||||||
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
|
children,
|
||||||
|
screenshot,
|
||||||
|
right = false,
|
||||||
|
}: FeatureSectionProps) {
|
||||||
|
const featureText = (
|
||||||
|
<div className={styles.featureText}>
|
||||||
|
<div className={styles.featureTitle}>{title}</div>
|
||||||
|
<div className={styles.featureDesc}>{children}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const featureScreenshot = (
|
||||||
|
<div className={styles.featureScreenshotOuter}>
|
||||||
|
<div className={styles.featureScreenshotGlow} />
|
||||||
|
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.featureContainer}`}>
|
||||||
|
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useUserStore();
|
|
||||||
const { isMobile } = useDownloadAppScreen();
|
const { isMobile } = useDownloadAppScreen();
|
||||||
const isLoggedIn = user.authToken && user.currentUser;
|
|
||||||
|
|
||||||
function handleGetStarted() {
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
if (isMobile) {
|
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
|
||||||
navigate("/download-app");
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
} else if (isLoggedIn) {
|
const downloadSectionRef = useRef<HTMLElement>(null);
|
||||||
navigate("/chat");
|
|
||||||
} else {
|
const scrollToDownload = () => {
|
||||||
navigate("/login");
|
const section = downloadSectionRef.current;
|
||||||
}
|
const header = document.querySelector<HTMLElement>("[data-home-header]");
|
||||||
|
if (!section) return;
|
||||||
|
const headerHeight = header?.getBoundingClientRect().height ?? 0;
|
||||||
|
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
|
||||||
|
window.scrollTo({ top: targetY, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = (os: DownloadOs): boolean => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openBtn = (
|
setDialogOs(os);
|
||||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
setDialogOpen(true);
|
||||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
|
||||||
</MaterialButton>
|
const link = document.createElement("a");
|
||||||
);
|
link.href = `/api/download/${os}`;
|
||||||
|
link.download = "";
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
|
||||||
|
const detectedOs = detectOs();
|
||||||
|
if (os === detectedOs) return "filled";
|
||||||
|
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
|
||||||
|
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
|
||||||
|
return "outlined";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homepage}>
|
<div className={styles.homepage}>
|
||||||
<header className={styles.homepageHeader}>
|
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<div className={styles.logo}>
|
|
||||||
<h1>FromChat</h1>
|
|
||||||
<span className={styles.tagline}>100% открытый мессенджер</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.headerNav}>
|
|
||||||
<GitHubLink>
|
|
||||||
<MaterialButton variant="text">GitHub</MaterialButton>
|
|
||||||
</GitHubLink>
|
|
||||||
<SupportLink>
|
|
||||||
<MaterialButton variant="text">Поддержка</MaterialButton>
|
|
||||||
</SupportLink>
|
|
||||||
|
|
||||||
{openBtn}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className={styles.hero}>
|
<section className={styles.title}>
|
||||||
<div className={styles.container}>
|
<div className={styles.titleLogoWrapper}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.titleLogo} />
|
||||||
<h2 className={styles.heroTitle}>
|
</div>
|
||||||
Безопасный мессенджер с открытым исходным кодом
|
<div className={styles.titleContent}>FromChat</div>
|
||||||
</h2>
|
<div className={styles.titleDesc}>
|
||||||
<p className={styles.heroDescription}>
|
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
|
||||||
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
|
</div>
|
||||||
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
|
<div className={styles.titleButtons}>
|
||||||
</p>
|
{isMobile ? null : (
|
||||||
<div className={styles.heroActions}>
|
|
||||||
{openBtn}
|
|
||||||
{!isMobile && (
|
|
||||||
<MaterialButton
|
<MaterialButton
|
||||||
variant="outlined"
|
variant="filled"
|
||||||
onClick={() => navigate("/register")}>
|
onClick={() => navigate("/auth?mode=login")}
|
||||||
Зарегистрироваться
|
icon="devices"
|
||||||
|
>
|
||||||
|
Открыть веб-версию
|
||||||
</MaterialButton>
|
</MaterialButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
<SplitButton
|
||||||
</div>
|
variant={isMobile ? "filled" : "tonal"}
|
||||||
<div className={styles.heroVisual}>
|
text="Скачать приложение"
|
||||||
<div className={styles.chatPreview}>
|
icon="download"
|
||||||
<div className={styles.chatWindow}>
|
onPrimaryClick={() => triggerDownload(detectOs())}
|
||||||
<div className={styles.chatHeader}>
|
menuOpen={menuOpen}
|
||||||
<div className={styles.chatTitle}>Общий чат</div>
|
onMenuOpen={setMenuOpen}
|
||||||
<div className={styles.onlineIndicator}>●</div>
|
menu={(
|
||||||
</div>
|
<MaterialList>
|
||||||
<div className={styles.chatMessages}>
|
{ALL_OS.map((os) => (
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
<MaterialListItem
|
||||||
<div className={styles.messageAvatar}>А</div>
|
key={os}
|
||||||
<div className={styles.messageContent}>
|
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
|
||||||
<div className={styles.messageText}>Привет! Как дела?</div>
|
headline={OS_CONFIG[os].label}
|
||||||
<div className={styles.messageTime}>14:30</div>
|
rounded
|
||||||
</div>
|
onClick={() => {
|
||||||
</div>
|
if (triggerDownload(os)) setMenuOpen(false);
|
||||||
<div className={`${styles.message} ${styles.sent}`}>
|
}}
|
||||||
<div className={styles.messageContent}>
|
>
|
||||||
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
|
{["windows", "linux", "macos"].includes(os) && (
|
||||||
<div className={styles.messageTime}>14:32</div>
|
<span
|
||||||
</div>
|
slot="icon"
|
||||||
</div>
|
className={styles.menuCustomIcon}
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
style={{
|
||||||
<div className={styles.messageAvatar}>Б</div>
|
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
<div className={styles.messageContent}>
|
} as React.CSSProperties}
|
||||||
<div className={styles.messageText}>Отправляю файл 📎</div>
|
/>
|
||||||
<div className={styles.messageTime}>14:35</div>
|
)}
|
||||||
</div>
|
</MaterialListItem>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</MaterialList>
|
||||||
</div>
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.features}>
|
<section className={styles.features}>
|
||||||
<div className={styles.container}>
|
<FeatureSection
|
||||||
<h3 className={styles.sectionTitle}>Возможности</h3>
|
title={<>Общий чат</>}
|
||||||
<div className={styles.featuresGrid}>
|
screenshot={generalChatScreenshot}
|
||||||
<div className={styles.featureCard}>
|
right
|
||||||
<div className={styles.featureIcon}>
|
>
|
||||||
<MaterialIcon name="security" />
|
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
|
||||||
</div>
|
</FeatureSection>
|
||||||
<h4>End-to-End Шифрование</h4>
|
<FeatureSection
|
||||||
<p>
|
title={<>Личные сообщения</>}
|
||||||
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
|
screenshot={dmScreenshot}>
|
||||||
Только вы и получатель можете прочитать сообщения.
|
Общайтесь с одним человеком в личной переписке.
|
||||||
</p>
|
</FeatureSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="code" />
|
|
||||||
</div>
|
|
||||||
<h4>100% открытый код</h4>
|
|
||||||
<p>
|
|
||||||
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
|
|
||||||
внести изменения или развернуть свой сервер.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="attach_file" />
|
|
||||||
</div>
|
|
||||||
<h4>Обмен Файлами</h4>
|
|
||||||
<p>
|
|
||||||
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
|
|
||||||
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="notifications" />
|
|
||||||
</div>
|
|
||||||
<h4>Уведомления</h4>
|
|
||||||
<p>
|
|
||||||
Получайте push-уведомления в браузере и настольном приложении.
|
|
||||||
Никогда не пропустите важное сообщение.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="edit" />
|
|
||||||
</div>
|
|
||||||
<h4>Редактирование</h4>
|
|
||||||
<p>
|
|
||||||
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
|
|
||||||
для лучшего контекста общения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="computer" />
|
|
||||||
</div>
|
|
||||||
<h4>Кроссплатформенность</h4>
|
|
||||||
<p>
|
|
||||||
Работает в браузере и как настольное приложение для Windows,
|
|
||||||
macOS и Linux. Единый интерфейс везде.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.download}>
|
<section ref={downloadSectionRef} className={styles.download}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.downloadContent}>
|
<div className={styles.downloadContent}>
|
||||||
<h3>Скачайте приложение</h3>
|
<h3>Скачайте приложение</h3>
|
||||||
<p>
|
<p>
|
||||||
Для лучшего опыта используйте настольное приложение с поддержкой
|
Настольное приложение с уведомлениями и автономной работой
|
||||||
уведомлений и автономной работы.
|
или мобильное приложение для Android и iOS.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.downloadButtons}>
|
<table className={styles.downloadTable}>
|
||||||
{!isMobile ? (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<a
|
<th>Платформа</th>
|
||||||
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
|
<th>Описание</th>
|
||||||
target="_blank"
|
<th />
|
||||||
rel="noopener noreferrer"
|
</tr>
|
||||||
>
|
</thead>
|
||||||
<MaterialButton variant="filled">
|
<tbody>
|
||||||
<MaterialIcon name="download" slot="icon" />
|
{ALL_OS.map((os) => (
|
||||||
Скачать для ПК
|
<tr key={os}>
|
||||||
</MaterialButton>
|
<td className={styles.downloadTableOs}>
|
||||||
</a>
|
<span className={styles.downloadTableOsContent}>
|
||||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
{["windows", "linux", "macos"].includes(os) ? (
|
||||||
<MaterialIcon name="language" slot="icon" />
|
<span
|
||||||
Веб-версия
|
className={styles.tableOsIcon}
|
||||||
</MaterialButton>
|
style={{
|
||||||
</>
|
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
<MaterialIcon
|
||||||
Скачать приложение
|
name={OS_CONFIG[os].icon}
|
||||||
</MaterialButton>
|
className={styles.downloadTableIcon}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span>{OS_CONFIG[os].label}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableDesc}>
|
||||||
|
<span className={styles.downloadTableDescInner}>
|
||||||
|
{OS_CONFIG[os].description}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableAction}>
|
||||||
|
<span className={styles.downloadTableActionInner}>
|
||||||
|
<MaterialButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButton}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</MaterialButton>
|
||||||
|
<MaterialIconButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButtonIcon}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
title={`Скачать ${OS_CONFIG[os].label}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -232,7 +240,8 @@ export default function HomePage() {
|
|||||||
<div className={styles.ctaContent}>
|
<div className={styles.ctaContent}>
|
||||||
<h3>Готовы начать общение?</h3>
|
<h3>Готовы начать общение?</h3>
|
||||||
<p>
|
<p>
|
||||||
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
|
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
|
||||||
|
или звоните — всё бесплатно и с открытым кодом.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.ctaActions}>
|
<div className={styles.ctaActions}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -259,24 +268,8 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className={styles.homepageFooter}>
|
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
|
||||||
<div className={styles.container}>
|
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.footerContent}>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Ссылки</h4>
|
|
||||||
<GitHubLink>GitHub</GitHubLink>
|
|
||||||
<SupportLink>Поддержка</SupportLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Лицензия</h4>
|
|
||||||
<p>GPL-3.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerBottom}>
|
|
||||||
<p>© 2025 FromChat. Сделано программистом denis0001-dev с ❤️ для свободы общения.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
|
||||||
|
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
|
||||||
|
$glow-purple: rgba(147, 51, 234, 0.5);
|
||||||
|
|
||||||
|
@mixin gradient-text {
|
||||||
|
background: $gradient-rainbow;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px $glow-purple;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "@/css/colors" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
.downloadDialog {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 20px;
|
||||||
|
border-top: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||||
|
background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageFooter {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: $color-dark-surface;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
|
||||||
|
@media (max-width: 635px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrandName {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerCopyright {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinks {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLink {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.2s ease, transform 0.15s ease;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:is(button) {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvg {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgTelegram {
|
||||||
|
mask-image: url('@/images/telegram.svg');
|
||||||
|
-webkit-mask-image: url('@/images/telegram.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgMax {
|
||||||
|
mask-image: url('@/images/max.svg');
|
||||||
|
-webkit-mask-image: url('@/images/max.svg');
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerInner {
|
||||||
|
max-width: 960px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-surface, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCenterLinks {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
@media (max-width: 730px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerSmallButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
.headerSmallButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
|
||||||
|
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
|
||||||
|
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
|
||||||
|
|
||||||
|
export function GitHubLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://t.me/denis0001-dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GITHUB_WEB, GITHUB_APP };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type DownloadOs = "windows" | "linux" | "macos" | "android" | "ios";
|
||||||
|
|
||||||
|
export const ALL_OS: DownloadOs[] = [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"macos",
|
||||||
|
"android",
|
||||||
|
"ios",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface OsInfo {
|
||||||
|
id: DownloadOs;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OS_CONFIG: Record<DownloadOs, OsInfo> = {
|
||||||
|
windows: { id: "windows", label: "Windows", description: "ПК", icon: "computer" },
|
||||||
|
linux: { id: "linux", label: "Linux", description: "ПК", icon: "computer" },
|
||||||
|
macos: { id: "macos", label: "macOS", description: "Apple", icon: "computer" },
|
||||||
|
android: { id: "android", label: "Android", description: "APK", icon: "android" },
|
||||||
|
ios: { id: "ios", label: "iOS", description: "iPhone, iPad", icon: "phone_iphone" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectOs(): DownloadOs {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = (navigator.userAgent || navigator.platform || "").toLowerCase();
|
||||||
|
const platform = (navigator as any).userAgentData?.platform?.toLowerCase?.() ?? "";
|
||||||
|
const haystack = `${ua} ${platform}`;
|
||||||
|
|
||||||
|
if (haystack.includes("android")) {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("iphone") || haystack.includes("ipad") || haystack.includes("ipod")) {
|
||||||
|
return "ios";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("win")) {
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("mac")) {
|
||||||
|
return "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("linux")) {
|
||||||
|
return "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FromChatProtocol } from "@fromchat/protocol";
|
||||||
|
import { getCurrentKeys } from "@/core/api/user/auth";
|
||||||
|
|
||||||
|
let protocolInstance: FromChatProtocol | null = null;
|
||||||
|
|
||||||
|
export function getFromChatProtocol(): FromChatProtocol | null {
|
||||||
|
return protocolInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeFromChatProtocol(privateKey: Uint8Array): FromChatProtocol {
|
||||||
|
protocolInstance = new FromChatProtocol(privateKey);
|
||||||
|
return protocolInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrInitProtocol(): FromChatProtocol {
|
||||||
|
if (protocolInstance) {
|
||||||
|
return protocolInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = getCurrentKeys();
|
||||||
|
if (!keys) {
|
||||||
|
throw new Error("Keys not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return initializeFromChatProtocol(keys.privateKey);
|
||||||
|
}
|
||||||
@@ -17,18 +17,21 @@ import 'mdui/components/button';
|
|||||||
import 'mdui/components/text-field';
|
import 'mdui/components/text-field';
|
||||||
import 'mdui/components/button-icon';
|
import 'mdui/components/button-icon';
|
||||||
import 'mdui/components/switch';
|
import 'mdui/components/switch';
|
||||||
|
import 'mdui/components/ripple';
|
||||||
import 'mdui/components/chip';
|
import 'mdui/components/chip';
|
||||||
import 'mdui/components/badge';
|
import 'mdui/components/badge';
|
||||||
import "mdui/mdui.css";
|
import "mdui/mdui.css";
|
||||||
import 'mdui/components/circular-progress';
|
import 'mdui/components/circular-progress';
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
||||||
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
|
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react';
|
||||||
import type { TextField } from 'mdui/components/text-field';
|
import type { TextField } from 'mdui/components/text-field';
|
||||||
import type { Switch } from 'mdui/components/switch';
|
import type { Switch } from 'mdui/components/switch';
|
||||||
import type { Override } from '@/core/types';
|
import type { Override } from '@/core/types';
|
||||||
import type { Button } from 'mdui/components/button';
|
import type { Button } from 'mdui/components/button';
|
||||||
import type { ButtonIcon } from 'mdui/components/button-icon';
|
import type { ButtonIcon } from 'mdui/components/button-icon';
|
||||||
|
import type { Ripple } from 'mdui/components/ripple';
|
||||||
import type { Icon } from 'mdui/components/icon';
|
import type { Icon } from 'mdui/components/icon';
|
||||||
import type { Fab } from 'mdui/components/fab';
|
import type { Fab } from 'mdui/components/fab';
|
||||||
import type { Tabs } from 'mdui/components/tabs';
|
import type { Tabs } from 'mdui/components/tabs';
|
||||||
@@ -58,6 +61,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
|
|||||||
export type MDUIButton = Override<HTMLElement, Button>;
|
export type MDUIButton = Override<HTMLElement, Button>;
|
||||||
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
||||||
export type MDUIIcon = Override<HTMLElement, Icon>;
|
export type MDUIIcon = Override<HTMLElement, Icon>;
|
||||||
|
export type MDUIRipple = Override<HTMLElement, Ripple>;
|
||||||
export type MDUIFab = Override<HTMLElement, Fab>;
|
export type MDUIFab = Override<HTMLElement, Fab>;
|
||||||
export type MDUITabs = Override<HTMLElement, Tabs>;
|
export type MDUITabs = Override<HTMLElement, Tabs>;
|
||||||
export type MDUITab = Override<HTMLElement, Tab>;
|
export type MDUITab = Override<HTMLElement, Tab>;
|
||||||
@@ -141,3 +145,68 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
|
|||||||
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
||||||
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MaterialRippleProps = NoChildren<BasePropCustomization<"mdui-ripple", MDUIRipple>>;
|
||||||
|
export function MaterialRipple(props: MaterialRippleProps) {
|
||||||
|
return <mdui-ripple {...props as ComponentProps<"mdui-ripple">} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns pointer handlers that forward press and hover events to an mdui-ripple element.
|
||||||
|
* Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled);
|
||||||
|
* <button {...rippleHandlers}>
|
||||||
|
* <MaterialRipple ref={rippleRef} />
|
||||||
|
* ...
|
||||||
|
* </button>
|
||||||
|
*/
|
||||||
|
export function useRippleHandlers(
|
||||||
|
disabled = false
|
||||||
|
): {
|
||||||
|
rippleRef: RefObject<MDUIRipple | null>;
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerEnter: (e: React.PointerEvent) => void;
|
||||||
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
|
} {
|
||||||
|
const rippleRef = useRef<MDUIRipple | null>(null);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.button !== 0) return;
|
||||||
|
const ripple = rippleRef.current;
|
||||||
|
if (!ripple?.startPress) return;
|
||||||
|
ripple.startPress(e.nativeEvent);
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const endPress = () => {
|
||||||
|
ripple.endPress?.();
|
||||||
|
btn.removeEventListener("pointerup", endPress);
|
||||||
|
btn.removeEventListener("pointercancel", endPress);
|
||||||
|
btn.removeEventListener("pointerleave", endPress);
|
||||||
|
};
|
||||||
|
btn.addEventListener("pointerup", endPress);
|
||||||
|
btn.addEventListener("pointercancel", endPress);
|
||||||
|
btn.addEventListener("pointerleave", endPress);
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerEnter = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.startHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerLeave = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.endHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave };
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ declare global {
|
|||||||
interface SyntheticEvent<T = Element, E = Event> {
|
interface SyntheticEvent<T = Element, E = Event> {
|
||||||
target: EventTarget & T;
|
target: EventTarget & T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Augment DOM event listeners to provide typed target for ALL elements
|
// Augment DOM event listeners to provide typed target for ALL elements
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
/* Path mapping */
|
/* Path mapping */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"],
|
||||||
|
"@fromchat/protocol": ["./packages/fromchat-protocol/src"]
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
@@ -31,6 +32,6 @@
|
|||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "react"
|
"jsxImportSource": "react"
|
||||||
},
|
},
|
||||||
"include": ["src", "electron.d.ts"],
|
"include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"],
|
||||||
"exclude": ["**/__*/**", "__*"]
|
"exclude": ["**/__*/**", "__*"]
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,8 @@ export default defineConfig({
|
|||||||
plugins: plugins,
|
plugins: plugins,
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src")
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
"@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
@@ -89,6 +90,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
appType: "spa",
|
appType: "spa",
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
exclude: ["@fromchat/protocol"],
|
||||||
esbuildOptions: {
|
esbuildOptions: {
|
||||||
target: "es2022"
|
target: "es2022"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"install:pussh": "bash ./scripts/install:pussh.sh",
|
"install:pussh": "bash ./scripts/install:pussh.sh",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"generate:env": "bash ./scripts/generate:env.sh",
|
"generate:env": "bash ./scripts/generate:env.sh",
|
||||||
"deploy": "bash ./scripts/deploy.sh"
|
"deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"frontend/build/electron"
|
"frontend/build/electron"
|
||||||
@@ -51,9 +51,10 @@
|
|||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"baseline-browser-mapping": "^2.9.11",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"dotenv-cli": "^10.0.0",
|
"dotenv-cli": "^11.0.0",
|
||||||
"electron": "^38.1.2",
|
"electron": "^39.2.7",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup-plugin-visualizer": "^6.0.4",
|
"rollup-plugin-visualizer": "^6.0.4",
|
||||||
@@ -67,12 +68,16 @@
|
|||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
"vite-plugin-sass-dts": "^1.3.34"
|
"vite-plugin-sass-dts": "^1.3.34"
|
||||||
},
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"frontend/packages/fromchat-protocol"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fromchat/protocol": "workspace:*",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"marked": "^16.3.0",
|
"marked": "^17.0.1",
|
||||||
"mdui": "^2.1.4",
|
"mdui": "^2.1.4",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
|||||||
@@ -113,41 +113,99 @@ fi
|
|||||||
|
|
||||||
step "Authentication"
|
step "Authentication"
|
||||||
SSH_KEY_FILE="$HOME/.ssh/id_rsa"
|
SSH_KEY_FILE="$HOME/.ssh/id_rsa"
|
||||||
|
SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub"
|
||||||
|
|
||||||
# Ensure ssh-agent is running
|
# Ensure ssh-agent is running
|
||||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
if [ -z "$SSH_AUTH_SOCK" ]; then
|
||||||
eval "$(ssh-agent -s)" > /dev/null 2>&1
|
eval "$(ssh-agent -s)" > /dev/null 2>&1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check if SSH key exists
|
||||||
|
if [ ! -f "$SSH_KEY_FILE" ]; then
|
||||||
|
error "SSH key not found at $SSH_KEY_FILE"
|
||||||
|
echo " Please generate an SSH key pair first:"
|
||||||
|
echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Add SSH key to agent if not already loaded
|
# Add SSH key to agent if not already loaded
|
||||||
if [ -f "$SSH_KEY_FILE" ]; then
|
KEY_LOADED=false
|
||||||
# Check if key is already loaded
|
if ssh-add -l > /dev/null 2>&1; then
|
||||||
KEY_LOADED=false
|
|
||||||
if ssh-add -l > /dev/null 2>&1; then
|
|
||||||
# Check if this specific key is loaded by trying to match the public key
|
# Check if this specific key is loaded by trying to match the public key
|
||||||
KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}')
|
KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}')
|
||||||
if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then
|
if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then
|
||||||
KEY_LOADED=true
|
KEY_LOADED=true
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$KEY_LOADED" = false ]; then
|
|
||||||
substep "Adding SSH key to agent..."
|
|
||||||
ssh-add "$SSH_KEY_FILE" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warning "SSH key not found at $SSH_KEY_FILE"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test SSH connection once to cache the key (this will prompt for passphrase if needed)
|
if [ "$KEY_LOADED" = false ]; then
|
||||||
ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true
|
substep "Adding SSH key to agent..."
|
||||||
|
if ! ssh-add "$SSH_KEY_FILE" 2>/dev/null; then
|
||||||
|
error "Failed to add SSH key to agent. Check your key passphrase."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if SSH key authentication already works
|
||||||
|
if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "echo 'SSH key works'" >/dev/null 2>&1; then
|
||||||
|
# SSH key already works, no need to copy
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# Check if our public key is already on the server
|
||||||
|
KEY_CONTENT=$(cat "$SSH_KEY_PUB_FILE")
|
||||||
|
if ssh -o BatchMode=no -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "
|
||||||
|
grep -q '$KEY_CONTENT' ~/.ssh/authorized_keys 2>/dev/null
|
||||||
|
" >/dev/null 2>&1; then
|
||||||
|
# Key exists but authentication failed - might be permissions issue
|
||||||
|
error "SSH key found on server but authentication failed. Check server SSH configuration."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
# Key not on server, need to copy it
|
||||||
|
substep "SSH password: " -n
|
||||||
|
SSH_PASSWORD=$(read_password)
|
||||||
|
|
||||||
|
if [ -z "$SSH_PASSWORD" ]; then
|
||||||
|
error "No SSH password provided"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
substep "Copying SSH key to server..."
|
||||||
|
if command -v expect >/dev/null 2>&1; then
|
||||||
|
expect << EOF >/dev/null 2>&1
|
||||||
|
spawn ssh-copy-id -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i "$SSH_KEY_PUB_FILE" "$SERVER"
|
||||||
|
expect "password:"
|
||||||
|
send "$SSH_PASSWORD\r"
|
||||||
|
expect eof
|
||||||
|
EOF
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
true
|
||||||
|
else
|
||||||
|
error "Failed to copy SSH key to server"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
error "expect not available - cannot copy SSH key"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# SUDO AUTHENTICATION
|
# SUDO AUTHENTICATION
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
SUDO_PASSWORD=""
|
SUDO_PASSWORD=""
|
||||||
while true; do
|
# If SSH password was provided, try using it for sudo first
|
||||||
|
if [ -n "$SSH_PASSWORD" ]; then
|
||||||
|
if echo "$SSH_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
||||||
|
SUDO_PASSWORD="$SSH_PASSWORD"
|
||||||
|
export SUDO_PASSWORD
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If we don't have a working sudo password yet, prompt for it
|
||||||
|
if [ -z "$SUDO_PASSWORD" ]; then
|
||||||
|
while true; do
|
||||||
substep "Sudo password: " -n
|
substep "Sudo password: " -n
|
||||||
SUDO_PASSWORD=$(read_password)
|
SUDO_PASSWORD=$(read_password)
|
||||||
|
|
||||||
@@ -162,7 +220,8 @@ while true; do
|
|||||||
else
|
else
|
||||||
echo -n " " && error "Invalid password, please try again"
|
echo -n " " && error "Invalid password, please try again"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# BUILD PHASE
|
# BUILD PHASE
|
||||||
@@ -329,6 +388,7 @@ for SERVICE in $SERVICES; do
|
|||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
error "Build failed for $SERVICE"
|
error "Build failed for $SERVICE"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -347,15 +407,89 @@ if ! docker pussh --help > /dev/null 2>&1; then
|
|||||||
echo " Install: npm run install:pussh"
|
echo " Install: npm run install:pussh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect images
|
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
|
||||||
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
|
cd "$DEPLOYMENT_DIR"
|
||||||
|
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
|
||||||
|
IMAGES=()
|
||||||
|
|
||||||
|
for S in $COMPOSE_SERVICES; do
|
||||||
|
# Try to read explicit image: field from the compose config for this service
|
||||||
|
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
|
||||||
|
grep -A5 "^[[:space:]]*${S}:" | \
|
||||||
|
grep -m1 "image:" || true)
|
||||||
|
|
||||||
|
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
|
||||||
|
|
||||||
|
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
|
||||||
|
IMAGES+=("$IMAGE_FROM_COMPOSE")
|
||||||
|
else
|
||||||
|
# If service has a build section (we built it above), use the tag pattern used during build
|
||||||
|
TAG="${PROJECT_NAME}-${S}:latest"
|
||||||
|
# Only include the tag if the image exists locally (avoid pushing unrelated images)
|
||||||
|
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
|
||||||
|
IMAGES+=("$TAG")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
if [ ${#IMAGES[@]} -gt 0 ]; then
|
||||||
|
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify that all built images are among the detected images to be pushed.
|
||||||
|
# This prevents accidentally pushing unrelated images.
|
||||||
|
BUILT_COUNT=${#BUILT_IMAGES[@]}
|
||||||
|
MATCHING_BUILT=0
|
||||||
|
MISSING_FROM_DETECTED=()
|
||||||
|
for BI in "${BUILT_IMAGES[@]}"; do
|
||||||
|
found=false
|
||||||
|
for DI in "${IMAGES[@]}"; do
|
||||||
|
if [ "$BI" = "$DI" ]; then
|
||||||
|
found=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$found" = true ]; then
|
||||||
|
MATCHING_BUILT=$((MATCHING_BUILT + 1))
|
||||||
|
else
|
||||||
|
MISSING_FROM_DETECTED+=("$BI")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Also list detected images that weren't built locally (these are likely external images)
|
||||||
|
NOT_BUILT_DETECTED=()
|
||||||
|
for DI in "${IMAGES[@]}"; do
|
||||||
|
built=false
|
||||||
|
for BI in "${BUILT_IMAGES[@]}"; do
|
||||||
|
if [ "$DI" = "$BI" ]; then
|
||||||
|
built=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$built" = false ]; then
|
||||||
|
NOT_BUILT_DETECTED+=("$DI")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
|
||||||
|
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
|
||||||
|
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
|
||||||
|
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
|
||||||
|
fi
|
||||||
|
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
|
||||||
|
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
|
||||||
|
fi
|
||||||
|
echo "Aborting to avoid pushing incorrect images."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ${#IMAGES[@]} -eq 0 ]; then
|
if [ ${#IMAGES[@]} -eq 0 ]; then
|
||||||
error "No ${PROJECT_NAME} images found"
|
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Pre-pull unregistry image if needed
|
# Pre-pull unregistry image if needed
|
||||||
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1"
|
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry"
|
||||||
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
|
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
|
||||||
substep "Pulling unregistry image (one-time setup)..."
|
substep "Pulling unregistry image (one-time setup)..."
|
||||||
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
|
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ JWT_SECRET="$(openssl rand -base64 32)"
|
|||||||
TURN_USERNAME=<set>
|
TURN_USERNAME=<set>
|
||||||
TURN_SECRET=<set>
|
TURN_SECRET=<set>
|
||||||
DEPLOYMENT_SERVER=<set>
|
DEPLOYMENT_SERVER=<set>
|
||||||
|
FIREBASE_CERT=<set>
|
||||||
|
RELEASES_TOKEN=<set>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||