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
|
||||
@@ -575,4 +575,8 @@ backend/alembic/**
|
||||
!backend/alembic/env.py
|
||||
!backend/alembic/script.py.mako
|
||||
!frontend/src/css/lib
|
||||
**/*.module.scss.d.ts
|
||||
**/*.module.scss.d.ts
|
||||
|
||||
.cursor/plans
|
||||
tmp
|
||||
compliance_keypair.txt
|
||||
@@ -6,5 +6,6 @@
|
||||
"**/.husky/_": true,
|
||||
"**/.venv": true,
|
||||
"**/node_modules": true
|
||||
}
|
||||
},
|
||||
"python.terminal.activateEnvironment": false
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
@@ -7,17 +7,15 @@
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
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
|
||||
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
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
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.
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
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
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
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:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
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
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
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.
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
@@ -72,7 +60,7 @@ modification follow.
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
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
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
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
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
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.
|
||||
|
||||
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
|
||||
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>
|
||||
|
||||
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
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
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/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
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".
|
||||
If your software can interact with users remotely through a computer
|
||||
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
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
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.
|
||||
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/>.
|
||||
|
||||
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 sys
|
||||
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
|
||||
from models import User
|
||||
from constants import OWNER_USERNAME
|
||||
@@ -20,9 +20,10 @@ from slowapi.middleware import SlowAPIMiddleware
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
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:
|
||||
logger.info("Starting database migration check...")
|
||||
# Run migration in a separate process
|
||||
@@ -37,10 +38,10 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to run database migrations: {e}")
|
||||
raise
|
||||
|
||||
|
||||
try:
|
||||
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:
|
||||
owner.verified = True
|
||||
db.commit()
|
||||
@@ -108,6 +109,16 @@ app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
@app.middleware("http")
|
||||
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()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
@@ -164,4 +175,5 @@ app.include_router(profile.router)
|
||||
app.include_router(push.router, prefix="/push")
|
||||
app.include_router(webrtc.router, prefix="/webrtc")
|
||||
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 models import User, DeviceSession
|
||||
from db import SessionLocal
|
||||
import logging
|
||||
|
||||
security = HTTPBearer()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
# Зависимость для получения сессии БД
|
||||
def get_db():
|
||||
@@ -23,8 +25,17 @@ def get_current_user(
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = verify_token(token)
|
||||
try:
|
||||
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:
|
||||
logger.info("get_current_user: verify_token returned empty payload")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
@@ -32,6 +43,7 @@ def get_current_user(
|
||||
)
|
||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
||||
if not user:
|
||||
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
@@ -60,6 +72,7 @@ def get_current_user(
|
||||
)
|
||||
|
||||
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(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session revoked or not found",
|
||||
@@ -73,6 +86,7 @@ def get_current_user(
|
||||
# Session expired due to inactivity - revoke it
|
||||
device_session.revoked = True
|
||||
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(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired due to inactivity",
|
||||
@@ -85,6 +99,7 @@ def get_current_user(
|
||||
|
||||
# Check if user is suspended
|
||||
if user.suspended:
|
||||
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account suspended",
|
||||
@@ -93,6 +108,7 @@ def get_current_user(
|
||||
|
||||
# Check if user is deleted
|
||||
if user.deleted:
|
||||
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account deleted",
|
||||
|
||||
@@ -20,18 +20,33 @@ def run_migrations():
|
||||
Fully automated - handles all scenarios automatically.
|
||||
"""
|
||||
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
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
# Create Alembic configuration
|
||||
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
|
||||
|
||||
|
||||
# Disable Alembic's logging configuration to avoid interfering with FastAPI
|
||||
alembic_cfg.set_main_option("configure_logging", "false")
|
||||
|
||||
|
||||
# Set the database URL in the config
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
|
||||
# Check if any migration files exist
|
||||
versions_dir = os.path.join(current_dir, "alembic", "versions")
|
||||
|
||||
|
||||
@@ -113,6 +113,16 @@ class PushSubscription(Base):
|
||||
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):
|
||||
__tablename__ = "reaction"
|
||||
|
||||
|
||||
@@ -5,6 +5,11 @@ from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from pywebpush import webpush, WebPushException
|
||||
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")
|
||||
|
||||
@@ -12,6 +17,24 @@ class PushNotificationService:
|
||||
def __init__(self):
|
||||
self.vapid_private_key = os.getenv("VAPID_PRIVATE_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):
|
||||
raise ValueError("VAPID public or private key is None")
|
||||
@@ -57,42 +80,61 @@ class PushNotificationService:
|
||||
users = db.query(User).filter(User.id != message.user_id)
|
||||
if exclude_user_id:
|
||||
users = users.filter(User.id != exclude_user_id)
|
||||
|
||||
|
||||
for user in users:
|
||||
# Check if user has push subscription before trying to send
|
||||
# Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription.
|
||||
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all()
|
||||
payload_data = {
|
||||
"type": "public_message",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"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 not subscription:
|
||||
continue
|
||||
|
||||
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",
|
||||
"message_id": message.id,
|
||||
"sender_id": message.user_id,
|
||||
"sender_username": message.author.username
|
||||
}
|
||||
)
|
||||
if subscription:
|
||||
await self._send_notification_to_user(
|
||||
db, user.id, title, body, message.author.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send public message notifications: {e}")
|
||||
|
||||
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
|
||||
"""Send push notification for a new DM"""
|
||||
try:
|
||||
title = f"{sender.username}"
|
||||
body = "New direct message"
|
||||
payload_data = {
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"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,
|
||||
f"New message from {sender.username}",
|
||||
"You have a new direct message",
|
||||
sender.profile_picture,
|
||||
{
|
||||
"type": "dm",
|
||||
"dm_id": dm_envelope.id,
|
||||
"sender_id": sender.id,
|
||||
"sender_username": sender.username
|
||||
}
|
||||
db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send DM notification: {e}")
|
||||
@@ -136,6 +178,56 @@ class PushNotificationService:
|
||||
except Exception as 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:
|
||||
"""Unsubscribe a user from push notifications"""
|
||||
try:
|
||||
|
||||
@@ -14,3 +14,4 @@ user-agents>=2.2.0
|
||||
httpx>=0.27.2
|
||||
rich>=13.9.4
|
||||
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:
|
||||
_failed_login_attempts.pop(identifier, None)
|
||||
|
||||
def _is_admin(user: User) -> bool:
|
||||
return user.id == 1
|
||||
|
||||
def convert_user(user: User) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
@@ -49,7 +52,7 @@ def convert_user(user: User) -> dict:
|
||||
"display_name": user.display_name,
|
||||
"profile_picture": user.profile_picture,
|
||||
"bio": user.bio,
|
||||
"admin": user.username == OWNER_USERNAME,
|
||||
"admin": _is_admin(user),
|
||||
"verified": user.verified,
|
||||
"suspended": user.suspended or False,
|
||||
"suspension_reason": user.suspension_reason,
|
||||
@@ -61,7 +64,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
|
||||
return {
|
||||
"authenticated": True,
|
||||
"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
|
||||
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
|
||||
if not is_valid_username(username):
|
||||
raise HTTPException(
|
||||
@@ -219,13 +215,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
||||
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()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
@@ -355,7 +344,7 @@ def delete_user_as_owner(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# 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")
|
||||
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
# 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
|
||||
"""
|
||||
# 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")
|
||||
|
||||
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
|
||||
import io
|
||||
import json
|
||||
from pydantic import BaseModel
|
||||
from better_profanity import profanity as _bp
|
||||
from security.audit import log_access, log_dm, log_public_chat, log_security
|
||||
from security.profanity import contains_profanity
|
||||
from security.rate_limit import rate_limit_per_ip
|
||||
from websocket.utils import authenticate_user
|
||||
|
||||
from models import FcmToken
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@@ -50,8 +53,8 @@ _BURST_COUNT_THRESHOLD = 20
|
||||
_SHORT_MESSAGE_LENGTH = 8
|
||||
_SHORT_MESSAGE_REPEAT_LIMIT = 4
|
||||
|
||||
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque)
|
||||
_message_rate_cache: dict[int, deque[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[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id)
|
||||
_burst_last_logged: dict[int, float] = {}
|
||||
|
||||
|
||||
@@ -62,12 +65,23 @@ def _normalize_for_spam(text: str) -> str:
|
||||
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()
|
||||
|
||||
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:
|
||||
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.suspension_reason = reason
|
||||
db.commit()
|
||||
@@ -77,6 +91,7 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
reason=reason,
|
||||
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
|
||||
**extra,
|
||||
)
|
||||
try:
|
||||
@@ -86,8 +101,8 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
||||
|
||||
# Rate tracking for burst detection
|
||||
rate_bucket = _message_rate_cache[user.id]
|
||||
rate_bucket.append(now)
|
||||
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS:
|
||||
rate_bucket.append((now, message_id))
|
||||
while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS:
|
||||
rate_bucket.popleft()
|
||||
|
||||
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,
|
||||
)
|
||||
_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(
|
||||
"Automatic suspension: excessive message rate",
|
||||
"auto_suspension_public_burst",
|
||||
message_ids_to_delete=burst_message_ids,
|
||||
count=burst_count,
|
||||
window_seconds=_BURST_WINDOW_SECONDS,
|
||||
)
|
||||
return
|
||||
|
||||
# Similarity-based spam detection
|
||||
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:
|
||||
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(
|
||||
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
|
||||
)
|
||||
|
||||
history.append((normalized, content, now))
|
||||
history.append((normalized, content, now, message_id))
|
||||
|
||||
total_matches = prior_same + prior_similar + 1
|
||||
|
||||
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(
|
||||
"Automatic suspension: repeated short messages",
|
||||
"auto_suspension_public_spam",
|
||||
message_ids_to_delete=spam_message_ids,
|
||||
occurrences=prior_same + 1,
|
||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||
match_type="short",
|
||||
@@ -138,9 +162,20 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
|
||||
return
|
||||
|
||||
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(
|
||||
"Automatic suspension: repeated similar public messages",
|
||||
"auto_suspension_public_spam",
|
||||
message_ids_to_delete=spam_message_ids,
|
||||
similar_messages=total_matches,
|
||||
window_seconds=_SPAM_WINDOW_SECONDS,
|
||||
match_type="similar",
|
||||
@@ -371,7 +406,7 @@ async def _send_message_internal(
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
@@ -420,6 +455,97 @@ async def send_message(
|
||||
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")
|
||||
@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)):
|
||||
@@ -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")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
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}")
|
||||
@rate_limit_per_ip("20/minute")
|
||||
async def edit_message(
|
||||
request: Request,
|
||||
async def _edit_message_internal(
|
||||
message_id: int,
|
||||
edit_request: EditMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_user: User,
|
||||
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()
|
||||
|
||||
if not message:
|
||||
@@ -735,6 +899,18 @@ async def edit_message(
|
||||
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}")
|
||||
async def delete_message(
|
||||
message_id: int,
|
||||
@@ -1004,6 +1180,7 @@ class MessaggingSocketManager:
|
||||
|
||||
# Skip if this exact update was recently added
|
||||
if signature in self.recent_updates[websocket]:
|
||||
logger.warning(f"Update was skipped due to duplicate signature {signature}")
|
||||
return
|
||||
|
||||
# Add to pending updates and track signature
|
||||
@@ -1011,10 +1188,8 @@ class MessaggingSocketManager:
|
||||
self.recent_updates[websocket].add(signature)
|
||||
|
||||
# Limit recent updates cache size (keep last 100 signatures per websocket)
|
||||
if len(self.recent_updates[websocket]) > 100:
|
||||
# Remove oldest entries (simple FIFO by converting to list and keeping last 100)
|
||||
# Actually, we'll just clear and rebuild on next flush - simpler approach
|
||||
pass
|
||||
if len(self.recent_updates[websocket]) > 1:
|
||||
self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1])
|
||||
|
||||
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
|
||||
"""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),
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Maps English/Latin characters to their Cyrillic equivalents and vice versa
|
||||
# Also includes Greek, full-width, and other Unicode variants
|
||||
@@ -182,6 +194,8 @@ _LEET_MAP = {
|
||||
"н": "н", # Already mapped, but explicit
|
||||
# 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, ...]], ...] = (
|
||||
@@ -193,6 +207,18 @@ _SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
|
||||
_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:
|
||||
"""Normalize a single character, mapping homoglyphs to canonical form."""
|
||||
# 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
|
||||
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)
|
||||
|
||||
# For phrase matching, convert zero-width chars to spaces instead of stripping
|
||||
@@ -316,45 +345,49 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
|
||||
start = pos + 1
|
||||
|
||||
# Also check if profane word appears as a subsequence (allowing extra chars)
|
||||
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй"
|
||||
# Only do subsequence matching for words of length 4 or more to avoid false positives
|
||||
# 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)
|
||||
text_chars = list(normalized_lower)
|
||||
# Stricter ratio for shorter words, more lenient for longer words
|
||||
if len(word_lower) <= 5:
|
||||
max_span_ratio = 1.5 # Very strict for short words
|
||||
else:
|
||||
max_span_ratio = 2.0 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
# This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
|
||||
# Now applies to ALL words, not just length >= 4, to prevent bypasses
|
||||
word_chars = list(word_lower)
|
||||
text_chars = list(normalized_lower)
|
||||
|
||||
# Stricter span limits based on word length to prevent false positives
|
||||
# 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:
|
||||
max_span_ratio = 1.8 # Slightly more lenient for longer words
|
||||
|
||||
# Try to find the word as a subsequence
|
||||
i = 0 # position in text
|
||||
j = 0 # position in word
|
||||
seq_start = None
|
||||
|
||||
while i < len(text_chars) and j < len(word_chars):
|
||||
if text_chars[i] == word_chars[j]:
|
||||
if seq_start is None:
|
||||
seq_start = i
|
||||
j += 1
|
||||
if j == len(word_chars):
|
||||
# Found the word as subsequence
|
||||
seq_end = i + 1
|
||||
# Check if the span is reasonable (not too long)
|
||||
span_length = seq_end - seq_start
|
||||
max_allowed_span = int(len(word_lower) * max_span_ratio)
|
||||
if span_length <= max_allowed_span:
|
||||
# Only add if it's not already covered by exact match
|
||||
if (seq_start, seq_end) not in spans:
|
||||
spans.append((seq_start, seq_end))
|
||||
# Reset to find next occurrence - continue from after the end of this match
|
||||
next_start = seq_start + 1
|
||||
seq_start = None
|
||||
j = 0
|
||||
i = next_start
|
||||
continue
|
||||
i += 1
|
||||
|
||||
return spans
|
||||
|
||||
@@ -582,7 +615,13 @@ def contains_profanity(text: str) -> bool:
|
||||
|
||||
_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):
|
||||
return True
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
from fastapi import HTTPException, WebSocket
|
||||
from fastapi import HTTPException, WebSocket, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from websocket.registry import WebSocketHandlerRegistry
|
||||
from routes.messaging import (
|
||||
MessaggingSocketManager,
|
||||
_send_message_internal,
|
||||
_edit_message_internal,
|
||||
get_messages,
|
||||
edit_message,
|
||||
delete_message,
|
||||
@@ -211,14 +212,11 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
|
||||
@websocket_handler("editMessage", authRequired=True)
|
||||
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
|
||||
"""Edit a public chat message."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
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
|
||||
dummy_request = SimpleNamespace()
|
||||
response = await edit_message(dummy_request, message_id, request, user, db)
|
||||
response = await _edit_message_internal(message_id, edit_request, user, db)
|
||||
await manager.broadcast({
|
||||
"type": "messageEdited",
|
||||
"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
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
build:
|
||||
dockerfile: deployment/Dockerfile.backend
|
||||
context: ..
|
||||
environment:
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||
volumes:
|
||||
- data:/app/data
|
||||
- logs:/app/logs
|
||||
@@ -21,7 +22,7 @@ services:
|
||||
path: ../backend/requirements.txt
|
||||
|
||||
frontend:
|
||||
build:
|
||||
build:
|
||||
dockerfile: deployment/frontend/Dockerfile
|
||||
context: ..
|
||||
environment:
|
||||
@@ -41,8 +42,26 @@ services:
|
||||
- action: rebuild
|
||||
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:
|
||||
data:
|
||||
name: fromchat-data
|
||||
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
|
||||
WORKDIR /app
|
||||
# Copy package.json and workspace package directory first (needed for workspace resolution)
|
||||
COPY package.json .
|
||||
COPY frontend/packages/ frontend/packages/
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install --ignore-scripts
|
||||
|
||||
# 1.2. Build
|
||||
# 1.2. Copy remaining frontend code and build
|
||||
COPY frontend frontend
|
||||
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 { importPassword, deriveKEK, randomBytes } from "./kdf";
|
||||
import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
|
||||
|
||||
export interface PrivateKeyBundle {
|
||||
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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,16 @@ export interface X25519KeyPair {
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export type KeyPair = X25519KeyPair;
|
||||
|
||||
export function generateX25519KeyPair(): X25519KeyPair {
|
||||
const kp = nacl.box.keyPair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.secretKey };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
|
||||
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,3 +1,19 @@
|
||||
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 saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
|
||||
|
||||
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
const out = new Uint8Array(length);
|
||||
crypto.getRandomValues(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"]);
|
||||
@@ -13,19 +29,3 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | Array
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
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 saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt;
|
||||
const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info;
|
||||
|
||||
const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]);
|
||||
const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8);
|
||||
return new Uint8Array(bits);
|
||||
}
|
||||
|
||||
export function randomBytes(length: number): Uint8Array {
|
||||
const out = new Uint8Array(length);
|
||||
crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
@@ -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> {
|
||||
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
|
||||
const ivBuf: ArrayBuffer = iv instanceof Uint8Array
|
||||
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
|
||||
: (iv as ArrayBuffer);
|
||||
|
||||
// Normalize ciphertext to a contiguous ArrayBuffer slice
|
||||
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
|
||||
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
|
||||
: (ciphertext as ArrayBuffer);
|
||||
@@ -26,9 +24,8 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer
|
||||
}
|
||||
|
||||
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
|
||||
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
|
||||
: (rawKey as ArrayBuffer);
|
||||
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
}
|
||||
@@ -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 type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
||||
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
|
||||
import type { Headers } from "@/core/types";
|
||||
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
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 { 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 { fetchUserPublicKey } from "../crypto/identity";
|
||||
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> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Obtain the key
|
||||
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);
|
||||
const protocol = getOrInitProtocol();
|
||||
const senderPublicKey = ub64(senderPublicKeyB64);
|
||||
|
||||
return await protocol.decryptMessage(senderPublicKey, envelope);
|
||||
}
|
||||
|
||||
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> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// Encryption key
|
||||
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 protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
|
||||
|
||||
const payload: SendDMRequest = {
|
||||
recipientId: recipientId,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
salt: b64(wkSalt),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext)
|
||||
...encrypted
|
||||
};
|
||||
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> {
|
||||
// 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();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
const mk = randomBytes(32);
|
||||
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 wk = await importAesGcmKey(wkRaw);
|
||||
|
||||
const wrap = await aesGcmEncrypt(wk, mk);
|
||||
|
||||
const form = new FormData();
|
||||
@@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
|
||||
const data = new Uint8Array(await f.arrayBuffer());
|
||||
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
|
||||
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);
|
||||
form.append("files", new File([blob], serverName));
|
||||
}
|
||||
form.append("fileNames", JSON.stringify(names));
|
||||
|
||||
// Merge files metadata into plaintext JSON and encrypt
|
||||
let obj: DmEncryptedJSON;
|
||||
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)));
|
||||
// Encrypt the plaintext JSON with the same mk
|
||||
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
|
||||
form.append("dm_payload", JSON.stringify({
|
||||
recipientId: recipientId,
|
||||
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> {
|
||||
const keys = getCurrentKeys();
|
||||
if (!keys) throw new Error("Keys not initialized");
|
||||
|
||||
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap
|
||||
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);
|
||||
const protocol = getOrInitProtocol();
|
||||
const recipientPublicKey = ub64(recipientPublicKeyB64);
|
||||
|
||||
const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
|
||||
|
||||
await request({
|
||||
type: "dmEdit",
|
||||
credentials: { scheme: "Bearer", credentials: authToken },
|
||||
data: {
|
||||
id,
|
||||
iv: b64(encMsg.iv),
|
||||
ciphertext: b64(encMsg.ciphertext),
|
||||
iv2: b64(wrap.iv),
|
||||
wrappedMk: b64(wrap.ciphertext),
|
||||
salt: b64(wkSalt)
|
||||
...encrypted
|
||||
}
|
||||
} as DMEditRequest);
|
||||
}
|
||||
@@ -189,6 +150,4 @@ export async function markRead(id: number, authToken: string): Promise<void> {
|
||||
}
|
||||
|
||||
// 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 { getAuthHeaders } from "../user/auth";
|
||||
import type { BackupBlob } from "@/core/types";
|
||||
import api from "@/core/api";
|
||||
|
||||
/**
|
||||
* Fetches the current user's backup blob
|
||||
*/
|
||||
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`, {
|
||||
method: "GET",
|
||||
headers
|
||||
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
|
||||
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import { getAuthHeaders } from "./account";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
|
||||
import { getCurrentKeys } from "./account";
|
||||
import { request } from "@/core/websocket";
|
||||
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { API_BASE_URL } from "@/core/config";
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
|
||||
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric";
|
||||
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
|
||||
import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
|
||||
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
|
||||
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { randomBytes } from "@/utils/crypto/kdf";
|
||||
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
|
||||
import { b64, ub64 } from "@/utils/utils";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import api from "@/core/api";
|
||||
import type { WrappedSessionKeyPayload } from "@/core/types";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import api from "@/core/api";
|
||||
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
|
||||
import { request } from "@/core/websocket";
|
||||
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
|
||||
import { importAesGcmKey } from "@/utils/crypto/symmetric";
|
||||
import { importAesGcmKey } from "@fromchat/protocol";
|
||||
import E2EEWorker from "./e2eeWorker?worker";
|
||||
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 { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||
@@ -8,18 +8,23 @@ import { RegisterForm } from "./RegisterForm";
|
||||
import type { Variants, Transition } from "motion/react";
|
||||
import styles from "./auth.module.scss";
|
||||
|
||||
const MIN_HEIGHT = 400;
|
||||
|
||||
const slideVariants: Variants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 300 : -300,
|
||||
opacity: 0
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
opacity: 1
|
||||
opacity: 1,
|
||||
y: 0 // Ensure no vertical movement
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
x: direction > 0 ? -300 : 300,
|
||||
opacity: 0
|
||||
opacity: 0,
|
||||
y: 0 // Ensure no vertical movement
|
||||
})
|
||||
};
|
||||
|
||||
@@ -45,96 +50,65 @@ export default function AuthPage() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loginFormRef = 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 enteringElementRef = useRef<"login" | "register" | null>(null);
|
||||
const [effectActivated, setEffectActivated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevMode.current !== currentMode) {
|
||||
setDirection(currentMode === "register" ? 1 : -1);
|
||||
prevMode.current = currentMode;
|
||||
enteringElementRef.current = currentMode as "login" | "register";
|
||||
setIsTransitioning(true);
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
const measureActiveHeight = useCallback(() => {
|
||||
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
|
||||
if (activeComponent) {
|
||||
const height = activeComponent.scrollHeight;
|
||||
if (height > 0) {
|
||||
setContainerHeight(height);
|
||||
}
|
||||
}
|
||||
}, [currentMode, loginFormRef, registerFormRef]);
|
||||
// Setup ResizeObserver to watch for content changes
|
||||
useEffect(() => {
|
||||
const activeRef = currentMode === "login" ? 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();
|
||||
if (activeRef.current) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect.height;
|
||||
if (height > 0) {
|
||||
setContainerHeight(Math.max(height, MIN_HEIGHT));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId1);
|
||||
if (rafId2 !== null) {
|
||||
cancelAnimationFrame(rafId2);
|
||||
resizeObserver.observe(activeRef.current);
|
||||
|
||||
// Initial measurement
|
||||
const initialHeight = activeRef.current.scrollHeight;
|
||||
if (initialHeight > 0) {
|
||||
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
}, [currentMode]);
|
||||
|
||||
|
||||
function switchMode(newMode: "login" | "register") {
|
||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||
}
|
||||
|
||||
function handleAnimationComplete(
|
||||
currentMode: "login" | "register",
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
function handleAnimationComplete() {
|
||||
setIsTransitioning(false);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<AuthContainer>
|
||||
<div
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
position: "relative",
|
||||
style={{
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
||||
transition: "height 0.3s ease"
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
|
||||
}}
|
||||
onAnimationEnd={() => {
|
||||
setContainerHeight("auto");
|
||||
height: `${containerHeight}px`,
|
||||
transition: isTransitioning ? "height 0.3s ease" : "none"
|
||||
}}
|
||||
>
|
||||
<AnimatePresence mode="sync" custom={direction}>
|
||||
@@ -148,11 +122,8 @@ export default function AuthPage() {
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
className={styles.formWrapper}
|
||||
style={{
|
||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
||||
}}
|
||||
>
|
||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||
</motion.div>
|
||||
@@ -166,7 +137,7 @@ export default function AuthPage() {
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={slideTransition}
|
||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
className={styles.formWrapper}
|
||||
>
|
||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||
|
||||
@@ -98,6 +98,12 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
&.relative {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.authHeader {
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
|
||||
@@ -6,8 +6,7 @@ import { parse } from "marked";
|
||||
import { escape as escapeHtml } from "he";
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import api from "@/core/api";
|
||||
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
|
||||
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
|
||||
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
|
||||
import { useUserStore } from "@/state/user";
|
||||
import { useProfileStore } from "@/state/profile";
|
||||
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";
|
||||
|
||||
export default function DownloadAppPage() {
|
||||
return (
|
||||
<div className={styles.downloadAppScreen}>
|
||||
<div>
|
||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
||||
<div className={styles.downloadAppCard}>
|
||||
<h1>Скачайте приложение</h1>
|
||||
<p>
|
||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
||||
вам нужно скачать приложение мессенджера.
|
||||
Этот сайт не предназначен для работы на маленьких экранах.
|
||||
Выберите вашу платформу:
|
||||
</p>
|
||||
|
||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
||||
</a>
|
||||
|
||||
<div className={styles.downloadAppButtons}>
|
||||
<a href="/download?os=android" className={styles.downloadAppBtn}>
|
||||
<MaterialIcon name="android" />
|
||||
Android
|
||||
</a>
|
||||
<a href="/download?os=ios" className={styles.downloadAppBtn}>
|
||||
<MaterialIcon name="phone_iphone" />
|
||||
iOS
|
||||
</a>
|
||||
</div>
|
||||
<p>
|
||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
||||
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
|
||||
</p>
|
||||
|
||||
<a href="https://t.me/denis0001-dev">
|
||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
@use "../../css/material" as *;
|
||||
|
||||
.downloadAppScreen {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 100vw;
|
||||
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 { useUserStore } from "@/state/user";
|
||||
import styles from "./home.module.scss";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import styles from "@/pages/home/home.module.scss";
|
||||
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 }) {
|
||||
return (
|
||||
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
|
||||
);
|
||||
interface FeatureSectionProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
screenshot: string;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
function SupportLink({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
|
||||
function FeatureSection({
|
||||
title,
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useUserStore();
|
||||
const { isMobile } = useDownloadAppScreen();
|
||||
const isLoggedIn = user.authToken && user.currentUser;
|
||||
|
||||
function handleGetStarted() {
|
||||
if (isMobile) {
|
||||
navigate("/download-app");
|
||||
} else if (isLoggedIn) {
|
||||
navigate("/chat");
|
||||
} else {
|
||||
navigate("/login");
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const downloadSectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const scrollToDownload = () => {
|
||||
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 = (
|
||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
||||
</MaterialButton>
|
||||
);
|
||||
setDialogOs(os);
|
||||
setDialogOpen(true);
|
||||
|
||||
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 (
|
||||
<div className={styles.homepage}>
|
||||
<header className={styles.homepageHeader}>
|
||||
<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>
|
||||
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||
|
||||
<main>
|
||||
<section className={styles.hero}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.heroContent}>
|
||||
<h2 className={styles.heroTitle}>
|
||||
Безопасный мессенджер с открытым исходным кодом
|
||||
</h2>
|
||||
<p className={styles.heroDescription}>
|
||||
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
|
||||
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
|
||||
</p>
|
||||
<div className={styles.heroActions}>
|
||||
{openBtn}
|
||||
{!isMobile && (
|
||||
<MaterialButton
|
||||
variant="outlined"
|
||||
onClick={() => navigate("/register")}>
|
||||
Зарегистрироваться
|
||||
</MaterialButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.heroVisual}>
|
||||
<div className={styles.chatPreview}>
|
||||
<div className={styles.chatWindow}>
|
||||
<div className={styles.chatHeader}>
|
||||
<div className={styles.chatTitle}>Общий чат</div>
|
||||
<div className={styles.onlineIndicator}>●</div>
|
||||
</div>
|
||||
<div className={styles.chatMessages}>
|
||||
<div className={`${styles.message} ${styles.received}`}>
|
||||
<div className={styles.messageAvatar}>А</div>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Привет! Как дела?</div>
|
||||
<div className={styles.messageTime}>14:30</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.message} ${styles.sent}`}>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
|
||||
<div className={styles.messageTime}>14:32</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${styles.message} ${styles.received}`}>
|
||||
<div className={styles.messageAvatar}>Б</div>
|
||||
<div className={styles.messageContent}>
|
||||
<div className={styles.messageText}>Отправляю файл 📎</div>
|
||||
<div className={styles.messageTime}>14:35</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className={styles.title}>
|
||||
<div className={styles.titleLogoWrapper}>
|
||||
<div className={styles.titleLogo} />
|
||||
</div>
|
||||
<div className={styles.titleContent}>FromChat</div>
|
||||
<div className={styles.titleDesc}>
|
||||
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
|
||||
</div>
|
||||
<div className={styles.titleButtons}>
|
||||
{isMobile ? null : (
|
||||
<MaterialButton
|
||||
variant="filled"
|
||||
onClick={() => navigate("/auth?mode=login")}
|
||||
icon="devices"
|
||||
>
|
||||
Открыть веб-версию
|
||||
</MaterialButton>
|
||||
)}
|
||||
<SplitButton
|
||||
variant={isMobile ? "filled" : "tonal"}
|
||||
text="Скачать приложение"
|
||||
icon="download"
|
||||
onPrimaryClick={() => triggerDownload(detectOs())}
|
||||
menuOpen={menuOpen}
|
||||
onMenuOpen={setMenuOpen}
|
||||
menu={(
|
||||
<MaterialList>
|
||||
{ALL_OS.map((os) => (
|
||||
<MaterialListItem
|
||||
key={os}
|
||||
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
|
||||
headline={OS_CONFIG[os].label}
|
||||
rounded
|
||||
onClick={() => {
|
||||
if (triggerDownload(os)) setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{["windows", "linux", "macos"].includes(os) && (
|
||||
<span
|
||||
slot="icon"
|
||||
className={styles.menuCustomIcon}
|
||||
style={{
|
||||
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
</MaterialListItem>
|
||||
))}
|
||||
</MaterialList>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.features}>
|
||||
<div className={styles.container}>
|
||||
<h3 className={styles.sectionTitle}>Возможности</h3>
|
||||
<div className={styles.featuresGrid}>
|
||||
<div className={styles.featureCard}>
|
||||
<div className={styles.featureIcon}>
|
||||
<MaterialIcon name="security" />
|
||||
</div>
|
||||
<h4>End-to-End Шифрование</h4>
|
||||
<p>
|
||||
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
|
||||
Только вы и получатель можете прочитать сообщения.
|
||||
</p>
|
||||
</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>
|
||||
<FeatureSection
|
||||
title={<>Общий чат</>}
|
||||
screenshot={generalChatScreenshot}
|
||||
right
|
||||
>
|
||||
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
|
||||
</FeatureSection>
|
||||
<FeatureSection
|
||||
title={<>Личные сообщения</>}
|
||||
screenshot={dmScreenshot}>
|
||||
Общайтесь с одним человеком в личной переписке.
|
||||
</FeatureSection>
|
||||
</section>
|
||||
|
||||
<section className={styles.download}>
|
||||
<section ref={downloadSectionRef} className={styles.download}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.downloadContent}>
|
||||
<h3>Скачайте приложение</h3>
|
||||
<p>
|
||||
Для лучшего опыта используйте настольное приложение с поддержкой
|
||||
уведомлений и автономной работы.
|
||||
Настольное приложение с уведомлениями и автономной работой
|
||||
или мобильное приложение для Android и iOS.
|
||||
</p>
|
||||
<div className={styles.downloadButtons}>
|
||||
{!isMobile ? (
|
||||
<>
|
||||
<a
|
||||
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MaterialButton variant="filled">
|
||||
<MaterialIcon name="download" slot="icon" />
|
||||
Скачать для ПК
|
||||
</MaterialButton>
|
||||
</a>
|
||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
||||
<MaterialIcon name="language" slot="icon" />
|
||||
Веб-версия
|
||||
</MaterialButton>
|
||||
</>
|
||||
) : (
|
||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
||||
Скачать приложение
|
||||
</MaterialButton>
|
||||
)}
|
||||
</div>
|
||||
<table className={styles.downloadTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Платформа</th>
|
||||
<th>Описание</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_OS.map((os) => (
|
||||
<tr key={os}>
|
||||
<td className={styles.downloadTableOs}>
|
||||
<span className={styles.downloadTableOsContent}>
|
||||
{["windows", "linux", "macos"].includes(os) ? (
|
||||
<span
|
||||
className={styles.tableOsIcon}
|
||||
style={{
|
||||
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
) : (
|
||||
<MaterialIcon
|
||||
name={OS_CONFIG[os].icon}
|
||||
className={styles.downloadTableIcon}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</section>
|
||||
@@ -232,7 +240,8 @@ export default function HomePage() {
|
||||
<div className={styles.ctaContent}>
|
||||
<h3>Готовы начать общение?</h3>
|
||||
<p>
|
||||
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
|
||||
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
|
||||
или звоните — всё бесплатно и с открытым кодом.
|
||||
</p>
|
||||
<div className={styles.ctaActions}>
|
||||
{isMobile ? (
|
||||
@@ -259,24 +268,8 @@ export default function HomePage() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className={styles.homepageFooter}>
|
||||
<div className={styles.container}>
|
||||
<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>
|
||||
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
|
||||
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||
</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/button-icon';
|
||||
import 'mdui/components/switch';
|
||||
import 'mdui/components/ripple';
|
||||
import 'mdui/components/chip';
|
||||
import 'mdui/components/badge';
|
||||
import "mdui/mdui.css";
|
||||
import 'mdui/components/circular-progress';
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
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 { Switch } from 'mdui/components/switch';
|
||||
import type { Override } from '@/core/types';
|
||||
import type { Button } from 'mdui/components/button';
|
||||
import type { ButtonIcon } from 'mdui/components/button-icon';
|
||||
import type { Ripple } from 'mdui/components/ripple';
|
||||
import type { Icon } from 'mdui/components/icon';
|
||||
import type { Fab } from 'mdui/components/fab';
|
||||
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 MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
||||
export type MDUIIcon = Override<HTMLElement, Icon>;
|
||||
export type MDUIRipple = Override<HTMLElement, Ripple>;
|
||||
export type MDUIFab = Override<HTMLElement, Fab>;
|
||||
export type MDUITabs = Override<HTMLElement, Tabs>;
|
||||
export type MDUITab = Override<HTMLElement, Tab>;
|
||||
@@ -140,4 +144,69 @@ export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
|
||||
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
|
||||
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
||||
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> {
|
||||
target: EventTarget & T;
|
||||
}
|
||||
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Augment DOM event listeners to provide typed target for ALL elements
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
"noEmit": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@fromchat/protocol": ["./packages/fromchat-protocol/src"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
@@ -31,6 +32,6 @@
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
},
|
||||
"include": ["src", "electron.d.ts"],
|
||||
"include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"],
|
||||
"exclude": ["**/__*/**", "__*"]
|
||||
}
|
||||
@@ -70,7 +70,8 @@ export default defineConfig({
|
||||
plugins: plugins,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src")
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts")
|
||||
}
|
||||
},
|
||||
server: {
|
||||
@@ -89,6 +90,7 @@ export default defineConfig({
|
||||
},
|
||||
appType: "spa",
|
||||
optimizeDeps: {
|
||||
exclude: ["@fromchat/protocol"],
|
||||
esbuildOptions: {
|
||||
target: "es2022"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"install:pussh": "bash ./scripts/install:pussh.sh",
|
||||
"prepare": "husky",
|
||||
"generate:env": "bash ./scripts/generate:env.sh",
|
||||
"deploy": "bash ./scripts/deploy.sh"
|
||||
"deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
|
||||
},
|
||||
"files": [
|
||||
"frontend/build/electron"
|
||||
@@ -51,9 +51,10 @@
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"electron": "^38.1.2",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"electron": "^39.2.7",
|
||||
"husky": "^9.1.7",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup-plugin-visualizer": "^6.0.4",
|
||||
@@ -67,12 +68,16 @@
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-sass-dts": "^1.3.34"
|
||||
},
|
||||
"workspaces": [
|
||||
"frontend/packages/fromchat-protocol"
|
||||
],
|
||||
"dependencies": {
|
||||
"@fromchat/protocol": "workspace:*",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"escape-string-regexp": "^5.0.0",
|
||||
"he": "^1.2.0",
|
||||
"idb": "^8.0.3",
|
||||
"marked": "^16.3.0",
|
||||
"marked": "^17.0.1",
|
||||
"mdui": "^2.1.4",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -113,56 +113,115 @@ fi
|
||||
|
||||
step "Authentication"
|
||||
SSH_KEY_FILE="$HOME/.ssh/id_rsa"
|
||||
SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub"
|
||||
|
||||
# Ensure ssh-agent is running
|
||||
if [ -z "$SSH_AUTH_SOCK" ]; then
|
||||
eval "$(ssh-agent -s)" > /dev/null 2>&1
|
||||
fi
|
||||
|
||||
# Add SSH key to agent if not already loaded
|
||||
if [ -f "$SSH_KEY_FILE" ]; then
|
||||
# Check if key is already loaded
|
||||
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
|
||||
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
|
||||
KEY_LOADED=true
|
||||
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"
|
||||
# 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
|
||||
|
||||
# Test SSH connection once to cache the key (this will prompt for passphrase if needed)
|
||||
ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true
|
||||
# Add SSH key to agent if not already loaded
|
||||
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
|
||||
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
|
||||
KEY_LOADED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$KEY_LOADED" = false ]; then
|
||||
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_PASSWORD=""
|
||||
while true; do
|
||||
substep "Sudo password: " -n
|
||||
SUDO_PASSWORD=$(read_password)
|
||||
|
||||
if [ -z "$SUDO_PASSWORD" ]; then
|
||||
warning "No password provided - assuming passwordless sudo"
|
||||
break
|
||||
fi
|
||||
|
||||
if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
||||
# 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
|
||||
break
|
||||
else
|
||||
echo -n " " && error "Invalid password, please try again"
|
||||
fi
|
||||
done
|
||||
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
|
||||
SUDO_PASSWORD=$(read_password)
|
||||
|
||||
if [ -z "$SUDO_PASSWORD" ]; then
|
||||
warning "No password provided - assuming passwordless sudo"
|
||||
break
|
||||
fi
|
||||
|
||||
if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
|
||||
export SUDO_PASSWORD
|
||||
break
|
||||
else
|
||||
echo -n " " && error "Invalid password, please try again"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# BUILD PHASE
|
||||
@@ -329,6 +388,7 @@ for SERVICE in $SERVICES; do
|
||||
echo ""
|
||||
else
|
||||
error "Build failed for $SERVICE"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -347,15 +407,89 @@ if ! docker pussh --help > /dev/null 2>&1; then
|
||||
echo " Install: npm run install:pussh"
|
||||
fi
|
||||
|
||||
# Detect images
|
||||
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
|
||||
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
|
||||
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
|
||||
error "No ${PROJECT_NAME} images found"
|
||||
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
|
||||
fi
|
||||
|
||||
# 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
|
||||
substep "Pulling unregistry image (one-time setup)..."
|
||||
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_SECRET=<set>
|
||||
DEPLOYMENT_SERVER=<set>
|
||||
FIREBASE_CERT=<set>
|
||||
RELEASES_TOKEN=<set>
|
||||
EOF
|
||||
|
||||