33 Commits

75 changed files with 7494 additions and 1269 deletions
+12
View File
@@ -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
+5 -1
View File
@@ -575,4 +575,8 @@ backend/alembic/**
!backend/alembic/env.py !backend/alembic/env.py
!backend/alembic/script.py.mako !backend/alembic/script.py.mako
!frontend/src/css/lib !frontend/src/css/lib
**/*.module.scss.d.ts **/*.module.scss.d.ts
.cursor/plans
tmp
compliance_keypair.txt
+2 -1
View File
@@ -6,5 +6,6 @@
"**/.husky/_": true, "**/.husky/_": true,
"**/.venv": true, "**/.venv": true,
"**/node_modules": true "**/node_modules": true
} },
"python.terminal.activateEnvironment": false
} }
+65 -78
View File
@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble Preamble
The GNU General Public License is a free, copyleft license for The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works. software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the software for all its users.
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you Developers that use our General Public Licenses protect your rights
these rights or asking you to surrender the rights. Therefore, you have with two steps: (1) assert copyright on the software, and (2) offer
certain responsibilities if you distribute copies of the software, or if you this License which gives you legal permission to copy, distribute
you modify it: responsibilities to respect the freedom of others. and/or modify the software.
For example, if you distribute copies of such a program, whether A secondary benefit of defending all users' freedom is that
gratis or for a fee, you must pass on to the recipients the same improvements made in alternate versions of the program, if they
freedoms that you received. You must make sure that they, too, receive receive widespread use, become available for other developers to
or can get the source code. And you must show them these terms so they incorporate. Many developers of free software are heartened and
know their rights. encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps: The GNU Affero General Public License is designed specifically to
(1) assert copyright on the software, and (2) offer you this License ensure that, in such cases, the modified source code becomes available
giving you legal permission to copy, distribute and/or modify it. to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains An older license, called the Affero General Public License and
that there is no warranty for this free software. For both users' and published by Affero, was designed to accomplish similar goals. This is
authors' sake, the GPL requires that modified versions be marked as a different license, not a version of the Affero GPL, but Affero has
changed, so that their problems will not be attributed erroneously to released a new version of the Affero GPL which permits relicensing under
authors of previous versions. this license.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU General Public License. "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License. 13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License, but the work with which it is combined will remain governed by version
section 13, concerning interaction through a network will apply to the 3 of the GNU General Public License.
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will the GNU Affero General Public License from time to time. Such new versions
be similar in spirit to the present version, but may differ in detail to will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU Affero General Public License as published
the Free Software Foundation, either version 3 of the License, or by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details. GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short If your software can interact with users remotely through a computer
notice like this when it starts in an interactive mode: network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
<program> Copyright (C) <year> <name of author> interface could display a "Source" link that leads users to an archive
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. of the code. There are many ways you could offer source, and different
This is free software, and you are welcome to redistribute it solutions will be better for different programs; see section 13 for the
under certain conditions; type `show c' for details. specific requirements.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+17 -5
View File
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
from routes import account, messaging, profile, push, webrtc, devices, moderation from routes import account, messaging, profile, push, webrtc, devices, moderation, download
import logging import logging
from models import User from models import User
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
@@ -20,9 +20,10 @@ from slowapi.middleware import SlowAPIMiddleware
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup - run migration in separate process to avoid logging interference # Startup - run migration in subprocess to avoid logging interference
try: try:
logger.info("Starting database migration check...") logger.info("Starting database migration check...")
# Run migration in a separate process # Run migration in a separate process
@@ -37,10 +38,10 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.error(f"Failed to run database migrations: {e}") logger.error(f"Failed to run database migrations: {e}")
raise raise
try: try:
with SessionLocal() as db: with SessionLocal() as db:
owner = db.query(User).filter(User.username == OWNER_USERNAME).first() owner = db.query(User).filter(User.id == 1).first()
if owner and not owner.verified: if owner and not owner.verified:
owner.verified = True owner.verified = True
db.commit() db.commit()
@@ -108,6 +109,16 @@ app.add_middleware(SlowAPIMiddleware)
@app.middleware("http") @app.middleware("http")
async def access_logging_middleware(request: Request, call_next): async def access_logging_middleware(request: Request, call_next):
# Log incoming request and Authorization header presence for debugging auth issues
try:
auth_header = request.headers.get("authorization")
if auth_header:
short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header
logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short)
else:
logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path)
except Exception:
pass
start = time.perf_counter() start = time.perf_counter()
try: try:
response = await call_next(request) response = await call_next(request)
@@ -164,4 +175,5 @@ app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices") app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router) app.include_router(moderation.router)
app.include_router(download.router)
+17 -1
View File
@@ -5,8 +5,10 @@ from sqlalchemy.orm import Session
from utils import verify_token from utils import verify_token
from models import User, DeviceSession from models import User, DeviceSession
from db import SessionLocal from db import SessionLocal
import logging
security = HTTPBearer() security = HTTPBearer()
logger = logging.getLogger("uvicorn.error")
# Зависимость для получения сессии БД # Зависимость для получения сессии БД
def get_db(): def get_db():
@@ -23,8 +25,17 @@ def get_current_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
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: if not payload:
logger.info("get_current_user: verify_token returned empty payload")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token", detail="Invalid or expired token",
@@ -32,6 +43,7 @@ def get_current_user(
) )
user = db.query(User).filter(User.id == payload["user_id"]).first() user = db.query(User).filter(User.id == payload["user_id"]).first()
if not user: if not user:
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found", detail="User not found",
@@ -60,6 +72,7 @@ def get_current_user(
) )
if not device_session or device_session.revoked: if not device_session or device_session.revoked:
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked or not found", detail="Session revoked or not found",
@@ -73,6 +86,7 @@ def get_current_user(
# Session expired due to inactivity - revoke it # Session expired due to inactivity - revoke it
device_session.revoked = True device_session.revoked = True
db.commit() db.commit()
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity", detail="Session expired due to inactivity",
@@ -85,6 +99,7 @@ def get_current_user(
# Check if user is suspended # Check if user is suspended
if user.suspended: if user.suspended:
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended", detail="Account suspended",
@@ -93,6 +108,7 @@ def get_current_user(
# Check if user is deleted # Check if user is deleted
if user.deleted: if user.deleted:
logger.info("get_current_user: account deleted for user_id=%s", user.id)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Account deleted", detail="Account deleted",
+19 -4
View File
@@ -20,18 +20,33 @@ def run_migrations():
Fully automated - handles all scenarios automatically. Fully automated - handles all scenarios automatically.
""" """
try: try:
# FIRST: Check if database has any application tables (excluding alembic_version)
engine = create_engine(DATABASE_URL)
with engine.connect() as connection:
from sqlalchemy import inspect
inspector = inspect(connection)
existing_tables = [table for table in inspector.get_table_names()
if not table.startswith('sqlite_') and table != 'alembic_version']
# If no application tables exist, create them directly from models
if not existing_tables:
logger.info("No application tables found. Creating all tables directly from models...")
from models import Base
Base.metadata.create_all(bind=engine)
logger.info("All tables created successfully from models.")
# Get the directory where this script is located # Get the directory where this script is located
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
# Create Alembic configuration # Create Alembic configuration
alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) alembic_cfg = Config(os.path.join(current_dir, "alembic.ini"))
# Disable Alembic's logging configuration to avoid interfering with FastAPI # Disable Alembic's logging configuration to avoid interfering with FastAPI
alembic_cfg.set_main_option("configure_logging", "false") alembic_cfg.set_main_option("configure_logging", "false")
# Set the database URL in the config # Set the database URL in the config
alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL)
# Check if any migration files exist # Check if any migration files exist
versions_dir = os.path.join(current_dir, "alembic", "versions") versions_dir = os.path.join(current_dir, "alembic", "versions")
+10
View File
@@ -113,6 +113,16 @@ class PushSubscription(Base):
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class FcmToken(Base):
__tablename__ = "fcm_token"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True)
token = Column(Text, nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Reaction(Base): class Reaction(Base):
__tablename__ = "reaction" __tablename__ = "reaction"
+118 -26
View File
@@ -5,6 +5,11 @@ from typing import List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from pywebpush import webpush, WebPushException from pywebpush import webpush, WebPushException
from models import PushSubscription, User, Message, DMEnvelope from models import PushSubscription, User, Message, DMEnvelope
from models import FcmToken
import firebase_admin
from firebase_admin import credentials as firebase_credentials
from firebase_admin import messaging as firebase_messaging
import base64
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -12,6 +17,24 @@ class PushNotificationService:
def __init__(self): def __init__(self):
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY") self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY")
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY") self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY")
# Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported.
self.firebase_initialized = False
try:
firebase_cert = os.getenv("FIREBASE_CERT")
if not firebase_cert:
raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization")
# Support raw JSON or base64-encoded JSON in FIREBASE_CERT
decoded = base64.b64decode(firebase_cert).decode("utf-8")
sa_dict = json.loads(decoded)
cred = firebase_credentials.Certificate(sa_dict)
firebase_admin.initialize_app(cred)
self.firebase_initialized = True
logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)")
except Exception as e:
logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}")
raise
if (not self.vapid_public_key) or (not self.vapid_private_key): if (not self.vapid_public_key) or (not self.vapid_private_key):
raise ValueError("VAPID public or private key is None") raise ValueError("VAPID public or private key is None")
@@ -57,42 +80,61 @@ class PushNotificationService:
users = db.query(User).filter(User.id != message.user_id) users = db.query(User).filter(User.id != message.user_id)
if exclude_user_id: if exclude_user_id:
users = users.filter(User.id != exclude_user_id) users = users.filter(User.id != exclude_user_id)
for user in users: for user in users:
# Check if user has push subscription before trying to send # Check if user has push subscription before trying to send
# 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() subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
if not subscription: if subscription:
continue await self._send_notification_to_user(
db, user.id, title, body, message.author.profile_picture, payload_data
await self._send_notification_to_user( )
db, user.id,
f"New message from {message.author.username}",
message.content[:100] + ("..." if len(message.content) > 100 else ""),
message.author.profile_picture,
{
"type": "public_message",
"message_id": message.id,
"sender_id": message.user_id,
"sender_username": message.author.username
}
)
except Exception as e: except Exception as e:
logger.error(f"Failed to send public message notifications: {e}") logger.error(f"Failed to send public message notifications: {e}")
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User): async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
"""Send push notification for a new DM""" """Send push notification for a new DM"""
try: try:
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( await self._send_notification_to_user(
db, dm_envelope.recipient_id, db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data
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
}
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to send DM notification: {e}") logger.error(f"Failed to send DM notification: {e}")
@@ -136,6 +178,56 @@ class PushNotificationService:
except Exception as e: except Exception as e:
logger.error(f"Failed to send push notification to user {user_id}: {e}") logger.error(f"Failed to send push notification to user {user_id}: {e}")
def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict):
"""Send an FCM data-only push to a single device token using Firebase Admin SDK.
Notification display is handled by the app, not FCM."""
if not self.firebase_initialized:
raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)")
try:
# Send only data payload - let the app handle notification display
# This prevents FCM from auto-showing notifications
msg = firebase_messaging.Message(
token=token,
data={
"title": title,
"body": body,
**{k: str(v) for k, v in (data or {}).items()}
},
android=firebase_messaging.AndroidConfig(priority="high"),
apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"})
)
resp = firebase_messaging.send(msg)
return resp
except Exception as e:
logger.error(f"Firebase Admin send failed for token {token}: {e}")
raise
def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str):
"""Clean up FCM tokens that have permanent failures"""
try:
# Check for permanent failure indicators in the error message
permanent_errors = [
"unregistered", "invalidregistration", "notregistered",
"sender_id_mismatch", "invalid_argument"
]
error_lower = error_message.lower()
is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors)
if is_permanent:
logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}")
db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete()
db.commit()
else:
logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}")
except Exception as e:
logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}")
try:
db.rollback()
except Exception:
pass
async def unsubscribe_user(self, db: Session, user_id: int) -> bool: async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
"""Unsubscribe a user from push notifications""" """Unsubscribe a user from push notifications"""
try: try:
+1
View File
@@ -14,3 +14,4 @@ user-agents>=2.2.0
httpx>=0.27.2 httpx>=0.27.2
rich>=13.9.4 rich>=13.9.4
slowapi>=0.1.9 slowapi>=0.1.9
firebase_admin>=7.1.0
+8 -19
View File
@@ -39,6 +39,9 @@ def _record_failed_login(identifier: str) -> bool:
def _reset_failed_logins(identifier: str) -> None: def _reset_failed_logins(identifier: str) -> None:
_failed_login_attempts.pop(identifier, None) _failed_login_attempts.pop(identifier, None)
def _is_admin(user: User) -> bool:
return user.id == 1
def convert_user(user: User) -> dict: def convert_user(user: User) -> dict:
return { return {
"id": user.id, "id": user.id,
@@ -49,7 +52,7 @@ def convert_user(user: User) -> dict:
"display_name": user.display_name, "display_name": user.display_name,
"profile_picture": user.profile_picture, "profile_picture": user.profile_picture,
"bio": user.bio, "bio": user.bio,
"admin": user.username == OWNER_USERNAME, "admin": _is_admin(user),
"verified": user.verified, "verified": user.verified,
"suspended": user.suspended or False, "suspended": user.suspended or False,
"suspension_reason": user.suspension_reason, "suspension_reason": user.suspension_reason,
@@ -61,7 +64,7 @@ def check_auth(current_user: User = Depends(get_current_user)):
return { return {
"authenticated": True, "authenticated": True,
"username": current_user.username, "username": current_user.username,
"admin": current_user.username == OWNER_USERNAME "admin": _is_admin(current_user)
} }
@@ -177,13 +180,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
# Determine if owner already exists # Determine if owner already exists
owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None
# If owner not yet registered, only allow the owner to register
if not owner_exists and username != OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Регистрация временно закрыта до регистрации владельца"
)
# Validate input # Validate input
if not is_valid_username(username): if not is_valid_username(username):
raise HTTPException( raise HTTPException(
@@ -219,13 +215,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
detail="Пароли не совпадают" detail="Пароли не совпадают"
) )
# After owner exists, disallow registering the reserved owner username via public registration
if owner_exists and username == OWNER_USERNAME:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Это имя пользователя зарезервировано"
)
existing_user = db.query(User).filter(User.username == username).first() existing_user = db.query(User).filter(User.username == username).first()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
@@ -355,7 +344,7 @@ def delete_user_as_owner(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
# Only owner can delete users # Only owner can delete users
if current_user.username != OWNER_USERNAME: if _is_admin(current_user):
raise HTTPException(status_code=403, detail="Only owner can perform this action") raise HTTPException(status_code=403, detail="Only owner can perform this action")
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
@@ -363,7 +352,7 @@ def delete_user_as_owner(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
# Prevent deleting the owner account via API # Prevent deleting the owner account via API
if user.username == OWNER_USERNAME: if _is_admin(user):
raise HTTPException(status_code=400, detail="Cannot delete owner account") raise HTTPException(status_code=400, detail="Cannot delete owner account")
# Manually delete user's messages to satisfy FK constraints # Manually delete user's messages to satisfy FK constraints
@@ -567,7 +556,7 @@ async def delete_account(
Delete the current user's own account - preserves messages/DMs/reactions/files Delete the current user's own account - preserves messages/DMs/reactions/files
""" """
# Prevent admin/owner account self-deletion # Prevent admin/owner account self-deletion
if current_user.username == OWNER_USERNAME or current_user.id == 1: if _is_admin(current_user):
raise HTTPException(status_code=400, detail="Cannot delete admin/owner account") raise HTTPException(status_code=400, detail="Cannot delete admin/owner account")
await _delete_user_data(current_user, db) await _delete_user_data(current_user, db)
+381
View File
@@ -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
+196 -21
View File
@@ -24,12 +24,15 @@ from push_service import push_service
from PIL import Image from PIL import Image
import io import io
import json import json
from pydantic import BaseModel
from better_profanity import profanity as _bp from better_profanity import profanity as _bp
from security.audit import log_access, log_dm, log_public_chat, log_security from security.audit import log_access, log_dm, log_public_chat, log_security
from security.profanity import contains_profanity from security.profanity import contains_profanity
from security.rate_limit import rate_limit_per_ip from security.rate_limit import rate_limit_per_ip
from websocket.utils import authenticate_user from websocket.utils import authenticate_user
from models import FcmToken
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -50,8 +53,8 @@ _BURST_COUNT_THRESHOLD = 20
_SHORT_MESSAGE_LENGTH = 8 _SHORT_MESSAGE_LENGTH = 8
_SHORT_MESSAGE_REPEAT_LIMIT = 4 _SHORT_MESSAGE_REPEAT_LIMIT = 4
_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque) _recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id)
_message_rate_cache: dict[int, deque[float]] = defaultdict(deque) _message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id)
_burst_last_logged: dict[int, float] = {} _burst_last_logged: dict[int, float] = {}
@@ -62,12 +65,23 @@ def _normalize_for_spam(text: str) -> str:
return cleaned return cleaned
def _monitor_public_message_activity(user: User, content: str, db: Session) -> None: def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None:
now = time.time() now = time.time()
def suspend(reason: str, event: str, **extra: Any) -> None: def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None:
if user.suspended or user.id == 1: if user.suspended or user.id == 1:
return return
# Delete spam messages that triggered the ban
if message_ids_to_delete:
try:
deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False)
db.commit()
logger.info(f"Deleted {deleted_count} spam messages for user {user.id}")
except Exception as e:
logger.error(f"Failed to delete spam messages: {e}")
db.rollback()
user.suspended = True user.suspended = True
user.suspension_reason = reason user.suspension_reason = reason
db.commit() db.commit()
@@ -77,6 +91,7 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
reason=reason, reason=reason,
deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0,
**extra, **extra,
) )
try: try:
@@ -86,8 +101,8 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
# Rate tracking for burst detection # Rate tracking for burst detection
rate_bucket = _message_rate_cache[user.id] rate_bucket = _message_rate_cache[user.id]
rate_bucket.append(now) rate_bucket.append((now, message_id))
while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS:
rate_bucket.popleft() rate_bucket.popleft()
burst_count = len(rate_bucket) burst_count = len(rate_bucket)
@@ -103,12 +118,17 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
window_seconds=_BURST_WINDOW_SECONDS, window_seconds=_BURST_WINDOW_SECONDS,
) )
_burst_last_logged[user.id] = now _burst_last_logged[user.id] = now
# Get all message IDs from the burst window
burst_message_ids = [msg_id for _, msg_id in rate_bucket]
suspend( suspend(
"Automatic suspension: excessive message rate", "Automatic suspension: excessive message rate",
"auto_suspension_public_burst", "auto_suspension_public_burst",
message_ids_to_delete=burst_message_ids,
count=burst_count, count=burst_count,
window_seconds=_BURST_WINDOW_SECONDS, window_seconds=_BURST_WINDOW_SECONDS,
) )
return
# Similarity-based spam detection # Similarity-based spam detection
normalized = _normalize_for_spam(content) normalized = _normalize_for_spam(content)
@@ -116,21 +136,25 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: while history and now - history[0][2] > _SPAM_WINDOW_SECONDS:
history.popleft() history.popleft()
prior_same = sum(1 for prev_norm, _, _ in history if prev_norm == normalized) prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized)
prior_similar = sum( prior_similar = sum(
1 1
for prev_norm, _, _ in history for prev_norm, _, _, _ in history
if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD
) )
history.append((normalized, content, now)) history.append((normalized, content, now, message_id))
total_matches = prior_same + prior_similar + 1 total_matches = prior_same + prior_similar + 1
if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT:
# Get message IDs of all matching short messages
spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized]
spam_message_ids.append(message_id) # Include current message
suspend( suspend(
"Automatic suspension: repeated short messages", "Automatic suspension: repeated short messages",
"auto_suspension_public_spam", "auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
occurrences=prior_same + 1, occurrences=prior_same + 1,
window_seconds=_SPAM_WINDOW_SECONDS, window_seconds=_SPAM_WINDOW_SECONDS,
match_type="short", match_type="short",
@@ -138,9 +162,20 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N
return return
if total_matches >= _SPAM_MESSAGE_LIMIT: if total_matches >= _SPAM_MESSAGE_LIMIT:
# Get message IDs of all matching similar messages
spam_message_ids = []
for prev_norm, _, _, msg_id in history:
if prev_norm == normalized:
spam_message_ids.append(msg_id)
elif prev_norm and normalized and prev_norm != normalized:
similarity = SequenceMatcher(None, normalized, prev_norm).ratio()
if similarity >= _SPAM_SIMILARITY_THRESHOLD:
spam_message_ids.append(msg_id)
spam_message_ids.append(message_id) # Include current message
suspend( suspend(
"Automatic suspension: repeated similar public messages", "Automatic suspension: repeated similar public messages",
"auto_suspension_public_spam", "auto_suspension_public_spam",
message_ids_to_delete=spam_message_ids,
similar_messages=total_matches, similar_messages=total_matches,
window_seconds=_SPAM_WINDOW_SECONDS, window_seconds=_SPAM_WINDOW_SECONDS,
match_type="similar", match_type="similar",
@@ -371,7 +406,7 @@ async def _send_message_internal(
except Exception: except Exception:
pass pass
_monitor_public_message_activity(current_user, raw_content, db) _monitor_public_message_activity(current_user, raw_content, new_message.id, db)
message_payload = convert_message(new_message) message_payload = convert_message(new_message)
@@ -420,6 +455,97 @@ async def send_message(
return await _send_message_internal(message_request, current_user, db, files) return await _send_message_internal(message_request, current_user, db, files)
class RegisterFcmRequest(BaseModel):
token: str
@router.post("/push/register")
async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Register or update an FCM token for the authenticated user.
"""
token = body.token.strip() if body and body.token else None
if not token:
raise HTTPException(status_code=400, detail="Missing token")
try:
# If token already exists (from another device), reassign it to this user.
token_row = db.query(FcmToken).filter(FcmToken.token == token).first()
if token_row:
token_row.user_id = current_user.id
else:
# Create new token record (allow multiple tokens per user)
new = FcmToken(user_id=current_user.id, token=token)
db.add(new)
db.commit()
logger.info(f"Registered FCM token for user {current_user.id}: {token}")
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to save token")
return {"status": "success"}
@router.post("/push/unregister")
async def unregister_fcm_token(request: Request, body: RegisterFcmRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Unregister an FCM token. If `body.token` provided, remove only that token for the user.
If no token provided, remove all tokens for the user.
"""
try:
if body and body.token:
db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == body.token.strip()).delete()
else:
db.query(FcmToken).filter(FcmToken.user_id == current_user.id).delete()
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to remove token")
return {"status": "success"}
@router.post("/push/test")
async def push_test(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Send a test push to the current user's registered FCM token (for manual testing).
"""
try:
fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == current_user.id).all()
if not fcm_rows:
raise HTTPException(status_code=404, detail="No FCM token registered for user")
title = "FromChat test"
body = "This is a test push from the server"
data = {"type": "test", "timestamp": datetime.utcnow().isoformat()}
# Use push_service which uses Admin SDK internally; attempt to send to all tokens
failures = []
for fcm in fcm_rows:
try:
push_service._send_fcm_to_token(fcm.token, title, body, data)
except Exception as e:
logger.error(f"Failed to send test push to user {current_user.id} token {fcm.token}: {e}")
failures.append(str(e))
if failures and len(failures) == len(fcm_rows):
# All failed
raise HTTPException(status_code=500, detail=f"Failed to send push to any token: {failures}")
return {"status": "success", "sent": len(fcm_rows) - len(failures), "failed": len(failures)}
except HTTPException:
raise
except Exception as e:
logger.error(f"push_test error: {e}")
raise HTTPException(status_code=500, detail="Internal error")
@router.get("/get_messages") @router.get("/get_messages")
@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse
async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
@@ -435,6 +561,43 @@ async def get_messages(request: Request, current_user: User = Depends(get_curren
} }
class MarkReadRequest(BaseModel):
messageIds: list[int]
@router.get("/messages/new")
@rate_limit_per_ip("60/minute")
async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Return unread public messages (Message.is_read == False).
"""
new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all()
messages_data = [convert_message(msg) for msg in new_messages]
return {"status": "success", "messages": messages_data}
@router.post("/messages/read")
@rate_limit_per_ip("60/minute")
async def mark_messages_read(request: Request, read_request: MarkReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""
Mark specified message IDs as read (set Message.is_read = True).
"""
if not read_request or not isinstance(read_request.messageIds, list) or len(read_request.messageIds) == 0:
return {"status": "success", "updated": 0}
try:
updated_count = db.query(Message).filter(Message.id.in_(read_request.messageIds)).update({Message.is_read: True}, synchronize_session=False)
db.commit()
except Exception as e:
try:
db.rollback()
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to mark messages as read")
return {"status": "success", "updated": int(updated_count)}
@router.post("/dm/send") @router.post("/dm/send")
@rate_limit_per_ip("20/minute") @rate_limit_per_ip("20/minute")
async def dm_send( async def dm_send(
@@ -678,15 +841,16 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge
} }
@router.put("/edit_message/{message_id}") async def _edit_message_internal(
@rate_limit_per_ip("20/minute")
async def edit_message(
request: Request,
message_id: int, message_id: int,
edit_request: EditMessageRequest, edit_request: EditMessageRequest,
current_user: User = Depends(get_current_user), current_user: User,
db: Session = Depends(get_db) db: Session
): ) -> dict:
"""Internal function to edit a message without requiring a Request object.
This can be called from both HTTP endpoints and WebSocket handlers.
"""
message = db.query(Message).filter(Message.id == message_id).first() message = db.query(Message).filter(Message.id == message_id).first()
if not message: if not message:
@@ -735,6 +899,18 @@ async def edit_message(
return {"status": "success", "message": payload} return {"status": "success", "message": payload}
@router.put("/edit_message/{message_id}")
@rate_limit_per_ip("20/minute")
async def edit_message(
request: Request,
message_id: int,
edit_request: EditMessageRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
return await _edit_message_internal(message_id, edit_request, current_user, db)
@router.delete("/delete_message/{message_id}") @router.delete("/delete_message/{message_id}")
async def delete_message( async def delete_message(
message_id: int, message_id: int,
@@ -1004,6 +1180,7 @@ class MessaggingSocketManager:
# Skip if this exact update was recently added # Skip if this exact update was recently added
if signature in self.recent_updates[websocket]: if signature in self.recent_updates[websocket]:
logger.warning(f"Update was skipped due to duplicate signature {signature}")
return return
# Add to pending updates and track signature # Add to pending updates and track signature
@@ -1011,10 +1188,8 @@ class MessaggingSocketManager:
self.recent_updates[websocket].add(signature) self.recent_updates[websocket].add(signature)
# Limit recent updates cache size (keep last 100 signatures per websocket) # Limit recent updates cache size (keep last 100 signatures per websocket)
if len(self.recent_updates[websocket]) > 100: if len(self.recent_updates[websocket]) > 1:
# Remove oldest entries (simple FIFO by converting to list and keeping last 100) self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1])
# Actually, we'll just clear and rebuild on next flush - simpler approach
pass
async def _flush_updates(self, websocket: WebSocket, db: Session | None = None): async def _flush_updates(self, websocket: WebSocket, db: Session | None = None):
"""Flush pending updates for a WebSocket connection""" """Flush pending updates for a WebSocket connection"""
+81 -42
View File
@@ -17,7 +17,7 @@ _CUSTOM_RU_TERMS: Set[str] = {
"ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда",
"пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", "пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон",
"долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки",
"урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор",
"пидоры", "пидорас", "пидорасы", "пидорасов", "пидоры", "пидорас", "пидорасы", "пидорасов",
} }
@@ -46,6 +46,18 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = (
re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE),
) )
# Patterns to check in original text (before normalization) to catch visual bypasses
# These patterns check for special character combinations that visually form letters
_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = (
# Catch "}{" used to visually form "х" followed by "С0С" or similar patterns
# This catches "хуесос" written as "}{¥€С0С" or variations
# Matches: }{ + any characters (including special chars) + С/с + 0 + С/с
# The pattern allows any characters between to catch special chars like ¥€
re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
# Also catch "}{" followed by "уесос" with 0 instead of о
re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE),
)
# Map for normalizing homoglyphs (similar-looking characters) # Map for normalizing homoglyphs (similar-looking characters)
# Maps English/Latin characters to their Cyrillic equivalents and vice versa # Maps English/Latin characters to their Cyrillic equivalents and vice versa
# Also includes Greek, full-width, and other Unicode variants # Also includes Greek, full-width, and other Unicode variants
@@ -182,6 +194,8 @@ _LEET_MAP = {
"н": "н", # Already mapped, but explicit "н": "н", # Already mapped, but explicit
# Special characters # Special characters
"@": "а", "@": "а",
# Multi-character visual bypasses (handled separately in preprocessing)
# "}{" visually forms "х" - handled in _preprocess_visual_bypasses
} }
_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( _RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = (
@@ -193,6 +207,18 @@ _SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json")
_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} _PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {}
def _preprocess_visual_bypasses(text: str) -> str:
"""
Preprocess text to convert multi-character visual bypasses to their intended letters.
This handles cases like "}{" visually forming "х".
"""
result = text
# Convert "}{" to "х" (visual bypass for Cyrillic х)
# The curly braces visually form the letter х when placed together
result = result.replace("}{", "х")
return result
def _normalize_char(ch: str) -> str: def _normalize_char(ch: str) -> str:
"""Normalize a single character, mapping homoglyphs to canonical form.""" """Normalize a single character, mapping homoglyphs to canonical form."""
# First try direct mapping (preserves case for non-mapped chars) # First try direct mapping (preserves case for non-mapped chars)
@@ -257,7 +283,10 @@ def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False)
(normalized_text, position_map) where position_map[i] is the original (normalized_text, position_map) where position_map[i] is the original
position of the i-th character in normalized_text position of the i-th character in normalized_text
""" """
# First normalize Unicode (composed vs decomposed) # First preprocess visual bypasses (like "}{" -> "х")
text = _preprocess_visual_bypasses(text)
# Then normalize Unicode (composed vs decomposed)
normalized_unicode = unicodedata.normalize('NFKC', text) normalized_unicode = unicodedata.normalize('NFKC', text)
# For phrase matching, convert zero-width chars to spaces instead of stripping # For phrase matching, convert zero-width chars to spaces instead of stripping
@@ -316,45 +345,49 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -
start = pos + 1 start = pos + 1
# Also check if profane word appears as a subsequence (allowing extra chars) # Also check if profane word appears as a subsequence (allowing extra chars)
# This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй" # This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй"
# Only do subsequence matching for words of length 4 or more to avoid false positives # Now applies to ALL words, not just length >= 4, to prevent bypasses
# Use stricter span limits for shorter words to prevent false matches in long legitimate words word_chars = list(word_lower)
if len(word_lower) >= 4: text_chars = list(normalized_lower)
word_chars = list(word_lower)
text_chars = list(normalized_lower) # Stricter span limits based on word length to prevent false positives
# Stricter ratio for shorter words, more lenient for longer words # Shorter words get much stricter limits
if len(word_lower) <= 5: if len(word_lower) <= 3:
max_span_ratio = 1.5 # Very strict for short words max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй")
else: elif len(word_lower) == 4:
max_span_ratio = 2.0 # Slightly more lenient for longer words max_span_ratio = 1.4 # Strict for 4-char words
elif len(word_lower) <= 5:
# Try to find the word as a subsequence max_span_ratio = 1.5 # Moderate for 5-char words
i = 0 # position in text else:
j = 0 # position in word max_span_ratio = 1.8 # Slightly more lenient for longer words
seq_start = None
# Try to find the word as a subsequence
while i < len(text_chars) and j < len(word_chars): i = 0 # position in text
if text_chars[i] == word_chars[j]: j = 0 # position in word
if seq_start is None: seq_start = None
seq_start = i
j += 1 while i < len(text_chars) and j < len(word_chars):
if j == len(word_chars): if text_chars[i] == word_chars[j]:
# Found the word as subsequence if seq_start is None:
seq_end = i + 1 seq_start = i
# Check if the span is reasonable (not too long) j += 1
span_length = seq_end - seq_start if j == len(word_chars):
max_allowed_span = int(len(word_lower) * max_span_ratio) # Found the word as subsequence
if span_length <= max_allowed_span: seq_end = i + 1
# Only add if it's not already covered by exact match # Check if the span is reasonable (not too long)
if (seq_start, seq_end) not in spans: span_length = seq_end - seq_start
spans.append((seq_start, seq_end)) max_allowed_span = int(len(word_lower) * max_span_ratio)
# Reset to find next occurrence - continue from after the end of this match if span_length <= max_allowed_span:
next_start = seq_start + 1 # Only add if it's not already covered by exact match
seq_start = None if (seq_start, seq_end) not in spans:
j = 0 spans.append((seq_start, seq_end))
i = next_start # Reset to find next occurrence - continue from after the end of this match
continue next_start = seq_start + 1
i += 1 seq_start = None
j = 0
i = next_start
continue
i += 1
return spans return spans
@@ -582,7 +615,13 @@ def contains_profanity(text: str) -> bool:
_rebuild_dictionary() _rebuild_dictionary()
# Check phrase patterns first # Check original text patterns first (before normalization) to catch visual bypasses
# like "}{" used to form "х"
for pattern in _ORIGINAL_TEXT_PATTERNS:
if pattern.search(text):
return True
# Check phrase patterns
if _check_phrase_patterns(text): if _check_phrase_patterns(text):
return True return True
+4 -6
View File
@@ -3,13 +3,14 @@ import json
import logging import logging
import time import time
from typing import Any from typing import Any
from fastapi import HTTPException, WebSocket from fastapi import HTTPException, WebSocket, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from websocket.registry import WebSocketHandlerRegistry from websocket.registry import WebSocketHandlerRegistry
from routes.messaging import ( from routes.messaging import (
MessaggingSocketManager, MessaggingSocketManager,
_send_message_internal, _send_message_internal,
_edit_message_internal,
get_messages, get_messages,
edit_message, edit_message,
delete_message, delete_message,
@@ -211,14 +212,11 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses
@websocket_handler("editMessage", authRequired=True) @websocket_handler("editMessage", authRequired=True)
async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None:
"""Edit a public chat message.""" """Edit a public chat message."""
from types import SimpleNamespace
message_id = data["message_id"] message_id = data["message_id"]
request: EditMessageRequest = EditMessageRequest.model_validate(data) edit_request: EditMessageRequest = EditMessageRequest.model_validate(data)
# Create a dummy request object for the HTTP endpoint function response = await _edit_message_internal(message_id, edit_request, user, db)
dummy_request = SimpleNamespace()
response = await edit_message(dummy_request, message_id, request, user, db)
await manager.broadcast({ await manager.broadcast({
"type": "messageEdited", "type": "messageEdited",
"data": response["message"] "data": response["message"]
+114
View File
@@ -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
}
+13
View File
@@ -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
+22 -3
View File
@@ -1,6 +1,6 @@
services: services:
backend: backend:
build: build:
dockerfile: deployment/Dockerfile.backend dockerfile: deployment/Dockerfile.backend
context: .. context: ..
environment: environment:
@@ -8,6 +8,7 @@ services:
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
FIREBASE_CERT: ${FIREBASE_CERT}
volumes: volumes:
- data:/app/data - data:/app/data
- logs:/app/logs - logs:/app/logs
@@ -21,7 +22,7 @@ services:
path: ../backend/requirements.txt path: ../backend/requirements.txt
frontend: frontend:
build: build:
dockerfile: deployment/frontend/Dockerfile dockerfile: deployment/frontend/Dockerfile
context: .. context: ..
environment: environment:
@@ -41,8 +42,26 @@ services:
- action: rebuild - action: rebuild
path: package.json path: package.json
caddy:
build:
context: ./caddy
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "80:80"
- "443:443"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- certs:/root/site/certs
environment:
XDG_DATA_HOME: /root/site/certs
XDG_CONFIG_HOME: /root/site/certs
volumes: volumes:
data: data:
name: fromchat-data name: fromchat-data
logs: logs:
name: fromchat-logs name: fromchat-logs
certs:
name: fromchat-certs
+3 -1
View File
@@ -3,11 +3,13 @@ FROM node:24 AS frontend
# 1.1. Install npm dependencies # 1.1. Install npm dependencies
WORKDIR /app WORKDIR /app
# Copy package.json and workspace package directory first (needed for workspace resolution)
COPY package.json . COPY package.json .
COPY frontend/packages/ frontend/packages/
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm install --ignore-scripts npm install --ignore-scripts
# 1.2. Build # 1.2. Copy remaining frontend code and build
COPY frontend frontend COPY frontend frontend
RUN npm run frontend:build RUN npm run frontend:build
@@ -0,0 +1,7 @@
node_modules/
dist/
*.log
.DS_Store
package-lock.json
@@ -0,0 +1,8 @@
src/
tsconfig.json
node_modules/
package-lock.json
*.log
.DS_Store
@@ -0,0 +1,171 @@
# Publishing FromChat Protocol
This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages.
## Prerequisites
1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup)
2. **GitHub account**: For GitHub Packages
3. **Node.js**: Version 18 or higher
## Publishing to npm
### Important: Scoped Package Setup
The package uses the `@fromchat` scope. You have two options:
**Option A: Create an npm organization (Recommended)**
1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create)
2. Create an organization named `fromchat`
3. Add yourself as a member
4. Then proceed with publishing below
**Option B: Use unscoped package name**
If you prefer not to create an organization, change the package name in `package.json`:
```json
{
"name": "fromchat-protocol" // Remove the @fromchat/ scope
}
```
Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`.
### 1. Build the package
```bash
cd frontend/packages/fromchat-protocol
npm run build
```
This compiles TypeScript to JavaScript in the `dist/` directory.
### 2. Login to npm
```bash
npm login
```
Enter your npm username, password, and email.
### 3. Publish
**If using scoped package (`@fromchat/protocol`):**
```bash
npm publish --access public
```
**If using unscoped package (`fromchat-protocol`):**
```bash
npm publish
```
The `--access public` flag is required for scoped packages (packages starting with `@`).
### 4. Verify
Check your package at: `https://www.npmjs.com/package/@fromchat/protocol`
### 5. Update version for future releases
```bash
# Patch version (1.0.0 -> 1.0.1)
npm version patch
# Minor version (1.0.0 -> 1.1.0)
npm version minor
# Major version (1.0.0 -> 2.0.0)
npm version major
# Then publish
npm publish --access public
```
## Publishing to GitHub Packages
### 1. Create a GitHub Personal Access Token
1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
2. Generate a new token with `write:packages` and `read:packages` permissions
3. Save the token securely
### 2. Configure npm to use GitHub Packages
Create or edit `~/.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN
```
Or add to `package.json`:
```json
{
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}
```
### 3. Update package.json
Update the repository URL to match your GitHub repository:
```json
{
"repository": {
"type": "git",
"url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git",
"directory": "frontend/packages/fromchat-protocol"
}
}
```
### 4. Build and publish
```bash
cd frontend/packages/fromchat-protocol
npm run build
npm publish
```
### 5. Install from GitHub Packages
Users can install your package with:
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
Or add to `.npmrc`:
```
@fromchat:registry=https://npm.pkg.github.com
```
## Using the Published Package
### From npm
```bash
npm install @fromchat/protocol
```
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
```
### From GitHub Packages
```bash
npm install @fromchat/protocol@npm:@fromchat/protocol
```
## Notes
- The package is built to `dist/` directory
- Source files in `src/` are excluded from the published package
- Only `dist/` and `README.md` are included in the published package
- The package uses ES modules (ESM) format
- TypeScript definitions are included in `dist/`
@@ -0,0 +1,99 @@
# FromChat Protocol
Simple ECDH-based encryption protocol for direct messages.
## Overview
The FromChat Protocol provides end-to-end encryption for direct messages using:
- **X25519** (ECDH) for key exchange
- **HKDF** for key derivation
- **AES-GCM** for symmetric encryption
This module is completely independent and can be used in any JavaScript/TypeScript project.
## Protocol Flow
### Encryption
1. Generate a random message key (mk) - 32 bytes
2. Generate a random salt (wkSalt) - 16 bytes
3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)`
4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF
5. Encrypt message with mk using AES-GCM → (iv, ciphertext)
6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk)
7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }`
### Decryption
1. Derive shared secret from ECDH
2. Derive wrapping key from shared secret using salt from message
3. Decrypt wrappedMk to get mk
4. Decrypt ciphertext with mk
## Usage
```typescript
import { FromChatProtocol } from "@fromchat/protocol";
// Initialize with your private key
const protocol = new FromChatProtocol(privateKey);
// Encrypt a message
const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!");
// Decrypt a message
const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted);
```
## API
### `FromChatProtocol`
#### Constructor
- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key
#### Methods
- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise<EncryptedMessage>` - Encrypt a message
- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise<string>` - Decrypt a message
### Types
```typescript
interface EncryptedMessage {
iv: string; // Base64 encoded IV for message encryption
ciphertext: string; // Base64 encoded encrypted message
salt: string; // Base64 encoded salt for wrapping key derivation
iv2: string; // Base64 encoded IV for message key wrapping
wrappedMk: string; // Base64 encoded wrapped message key
}
```
## Backup & Key Management
The protocol also includes utilities for backing up and restoring private keys:
```typescript
import {
encryptBackupWithPassword,
decryptBackupWithPassword,
encodeBlob,
decodeBlob
} from "@fromchat/protocol";
// Create a backup of a private key
const bundle = { version: 1, privateKey: myPrivateKey };
const encrypted = await encryptBackupWithPassword("my-password", bundle);
const backupString = encodeBlob(encrypted); // Store this string
// Restore from backup
const encryptedBlob = decodeBlob(backupString);
const restored = await decryptBackupWithPassword("my-password", encryptedBlob);
```
## Security Notes
- Each message uses a fresh random message key
- The protocol does not provide forward secrecy
- Keys are derived using HKDF with SHA-256
- All encryption uses AES-GCM with 12-byte IVs
- Backup encryption uses PBKDF2 with 210,000 iterations
@@ -0,0 +1,54 @@
{
"name": "@fromchat/protocol",
"version": "1.0.0",
"description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": [
"encryption",
"ecdh",
"e2ee",
"end-to-end-encryption",
"x25519",
"aes-gcm",
"hkdf"
],
"author": "denis0001-dev",
"license": "GPL-3.0",
"repository": {
"type": "git",
"url": "https://github.com/Toolbox-io/FromChat.git",
"directory": "frontend/packages/fromchat-protocol"
},
"bugs": {
"url": "https://github.com/Toolbox-io/FromChat/issues"
},
"homepage": "https://github.com/Toolbox-io/FromChat#readme",
"dependencies": {
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@types/node": "^25.0.2",
"typescript": "^5.0.0"
},
"files": [
"dist",
"README.md"
],
"engines": {
"node": ">=24.0.0"
}
}
@@ -1,5 +1,4 @@
import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index";
import { importPassword, deriveKEK, randomBytes } from "./kdf";
export interface PrivateKeyBundle { export interface PrivateKeyBundle {
version: 1; version: 1;
@@ -65,4 +64,3 @@ export function decodeBlob(json: string): EncryptedBackupBlob {
return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) };
} }
@@ -6,18 +6,16 @@ export interface X25519KeyPair {
privateKey: Uint8Array; privateKey: Uint8Array;
} }
export type KeyPair = X25519KeyPair;
export function generateX25519KeyPair(): X25519KeyPair { export function generateX25519KeyPair(): X25519KeyPair {
const kp = nacl.box.keyPair(); const kp = nacl.box.keyPair();
return { publicKey: kp.publicKey, privateKey: kp.secretKey }; return { publicKey: kp.publicKey, privateKey: kp.secretKey };
} }
export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array {
// nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF.
return nacl.box.before(theirPublicKey, myPrivateKey); return nacl.box.before(theirPublicKey, myPrivateKey);
} }
export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> { export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<Uint8Array> {
return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32);
} }
@@ -0,0 +1,7 @@
// Re-export all crypto functions for convenience
export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric";
export type { X25519KeyPair } from "./asymmetric";
export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric";
export type { AesGcmCiphertext } from "./symmetric";
export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf";
@@ -1,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> { export async function importPassword(password: string): Promise<CryptoKey> {
const enc = new TextEncoder(); const enc = new TextEncoder();
return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); 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"] ["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> { export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise<Uint8Array> {
// Normalize IV to ArrayBuffer (12 bytes for AES-GCM)
const ivBuf: ArrayBuffer = iv instanceof Uint8Array const ivBuf: ArrayBuffer = iv instanceof Uint8Array
? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength)
: (iv as ArrayBuffer); : (iv as ArrayBuffer);
// Normalize ciphertext to a contiguous ArrayBuffer slice
const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array
? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength)
: (ciphertext as ArrayBuffer); : (ciphertext as ArrayBuffer);
@@ -26,9 +24,8 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer
} }
export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> { export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise<CryptoKey> {
// Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues
const keyBuffer = rawKey instanceof Uint8Array const keyBuffer = rawKey instanceof Uint8Array
? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) ? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength)
: (rawKey as ArrayBuffer); : (rawKey as ArrayBuffer);
return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); 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 -3
View File
@@ -1,9 +1,7 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto";
import type { Headers } from "@/core/types"; import type { Headers } from "@/core/types";
+25 -66
View File
@@ -1,28 +1,19 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth"; import { getAuthHeaders } from "../user/auth";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "../user/auth"; import { getCurrentKeys } from "../user/auth";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { fetchUserPublicKey } from "../crypto/identity"; import { fetchUserPublicKey } from "../crypto/identity";
import { fetchUsers, searchUsers } from "../user/search"; import { fetchUsers, searchUsers } from "../user/search";
import { getOrInitProtocol } from "@/utils/crypto/fromchatInit";
import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol";
export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> { export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise<string> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const senderPublicKey = ub64(senderPublicKeyB64);
// Obtain the key return await protocol.decryptMessage(senderPublicKey, envelope);
const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk));
// Decrypt
const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext));
return new TextDecoder().decode(msg);
} }
export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> {
@@ -39,27 +30,14 @@ export async function fetchMessages(userId: number, token: string, limit: number
} }
export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> { export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise<void> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const recipientPublicKey = ub64(recipientPublicKeyB64);
// Encryption key const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext);
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
// Encrypt the message
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext));
const wrap = await aesGcmEncrypt(wk, mk);
const payload: SendDMRequest = { const payload: SendDMRequest = {
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), ...encrypted
ciphertext: b64(encMsg.ciphertext),
salt: b64(wkSalt),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext)
}; };
if (replyToId) payload.replyToId = replyToId; if (replyToId) payload.replyToId = replyToId;
@@ -74,15 +52,16 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p
} }
export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> { export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise<void> {
// For files, we need to use the same message key for both the message and files
// So we'll do the encryption manually here to reuse the mk
const keys = getCurrentKeys(); const keys = getCurrentKeys();
if (!keys) throw new Error("Keys not initialized"); if (!keys) throw new Error("Keys not initialized");
const mk = randomBytes(32); const mk = randomBytes(32);
const wkSalt = randomBytes(16); const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw); const wk = await importAesGcmKey(wkRaw);
const wrap = await aesGcmEncrypt(wk, mk); const wrap = await aesGcmEncrypt(wk, mk);
const form = new FormData(); const form = new FormData();
@@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
const data = new Uint8Array(await f.arrayBuffer()); const data = new Uint8Array(await f.arrayBuffer());
const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data);
const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" });
const serverName = f.name; // server uses provided name const serverName = f.name;
names.push(serverName); names.push(serverName);
form.append("files", new File([blob], serverName)); form.append("files", new File([blob], serverName));
} }
form.append("fileNames", JSON.stringify(names)); form.append("fileNames", JSON.stringify(names));
// Merge files metadata into plaintext JSON and encrypt // Encrypt the plaintext JSON with the same mk
let obj: DmEncryptedJSON; const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson));
try {
obj = JSON.parse(plaintextJson);
} catch {
obj = { type: "text", data: { content: String(plaintextJson) } };
}
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj)));
form.append("dm_payload", JSON.stringify({ form.append("dm_payload", JSON.stringify({
recipientId: recipientId, recipientId: recipientId,
iv: b64(encMsg.iv), iv: b64(encMsg.iv),
@@ -128,28 +100,17 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64:
} }
export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> { export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise<void> {
const keys = getCurrentKeys(); const protocol = getOrInitProtocol();
if (!keys) throw new Error("Keys not initialized"); const recipientPublicKey = ub64(recipientPublicKeyB64);
// We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson);
const mk = randomBytes(32);
const wkSalt = randomBytes(16);
const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64));
const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1]));
const wk = await importAesGcmKey(wkRaw);
const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson));
const wrap = await aesGcmEncrypt(wk, mk);
await request({ await request({
type: "dmEdit", type: "dmEdit",
credentials: { scheme: "Bearer", credentials: authToken }, credentials: { scheme: "Bearer", credentials: authToken },
data: { data: {
id, id,
iv: b64(encMsg.iv), ...encrypted
ciphertext: b64(encMsg.ciphertext),
iv2: b64(wrap.iv),
wrappedMk: b64(wrap.ciphertext),
salt: b64(wkSalt)
} }
} as DMEditRequest); } as DMEditRequest);
} }
@@ -189,6 +150,4 @@ export async function markRead(id: number, authToken: string): Promise<void> {
} }
// Re-export user functions for convenience // Re-export user functions for convenience
export { fetchUsers, searchUsers, fetchUserPublicKey }; export { fetchUsers, searchUsers, fetchUserPublicKey };
+3 -3
View File
@@ -1,12 +1,12 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "../user/auth";
import type { BackupBlob } from "@/core/types"; import type { BackupBlob } from "@/core/types";
import api from "@/core/api";
/** /**
* Fetches the current user's backup blob * Fetches the current user's backup blob
*/ */
export async function fetchBackupBlob(token: string): Promise<string | null> { export async function fetchBackupBlob(token: string): Promise<string | null> {
const headers = getAuthHeaders(token, true); const headers = api.user.auth.getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "GET", method: "GET",
headers headers
@@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise<string | null> {
export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> { export async function uploadBackupBlob(blobJson: string, token: string): Promise<void> {
const payload: BackupBlob = { blob: blobJson } const payload: BackupBlob = { blob: blobJson }
const headers = getAuthHeaders(token, true); const headers = api.user.auth.getAuthHeaders(token, true);
const res = await fetch(`${API_BASE_URL}/crypto/backup`, { const res = await fetch(`${API_BASE_URL}/crypto/backup`, {
method: "POST", method: "POST",
headers, headers,
+1 -3
View File
@@ -1,8 +1,6 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account"; import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
+1 -3
View File
@@ -1,8 +1,6 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import { getAuthHeaders } from "./account"; import { getAuthHeaders } from "./account";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { randomBytes } from "@/utils/crypto/kdf";
import { getCurrentKeys } from "./account"; import { getCurrentKeys } from "./account";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types";
+1 -3
View File
@@ -1,9 +1,7 @@
import { API_BASE_URL } from "@/core/config"; import { API_BASE_URL } from "@/core/config";
import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types";
import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol";
import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { hkdfExtractAndExpand } from "@/utils/crypto/kdf";
import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; import { fetchPublicKey, uploadPublicKey } from "../crypto/identity";
import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup";
+1 -3
View File
@@ -1,7 +1,5 @@
import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol";
import { randomBytes } from "@/utils/crypto/kdf";
import { b64, ub64 } from "@/utils/utils"; import { b64, ub64 } from "@/utils/utils";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric";
import api from "@/core/api"; import api from "@/core/api";
import type { WrappedSessionKeyPayload } from "@/core/types"; import type { WrappedSessionKeyPayload } from "@/core/types";
+1 -1
View File
@@ -2,7 +2,7 @@ import api from "@/core/api";
import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types";
import { request } from "@/core/websocket"; import { request } from "@/core/websocket";
import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption";
import { importAesGcmKey } from "@/utils/crypto/symmetric"; import { importAesGcmKey } from "@fromchat/protocol";
import E2EEWorker from "./e2eeWorker?worker"; import E2EEWorker from "./e2eeWorker?worker";
import { delay } from "@/utils/utils"; import { delay } from "@/utils/utils";
@@ -0,0 +1,260 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
import useWindowSize from "@/core/hooks/useWindowSize";
import styles from "./css/split-button.module.scss";
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
interface SplitButtonProps {
text: ReactNode;
icon?: ReactNode | string;
menu: ReactNode;
menuOpen: boolean;
onMenuOpen: (open: boolean) => void;
onPrimaryClick?: () => void;
variant?: SplitButtonVariant;
disabled?: boolean;
className?: string;
menuAriaLabel?: string;
}
export function SplitButton({
text,
icon,
menu,
menuOpen: open,
onMenuOpen,
onPrimaryClick,
variant = "filled",
disabled = false,
className = "",
menuAriaLabel,
}: SplitButtonProps) {
const [isExiting, setIsExiting] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
top?: number;
bottom?: number;
left: number;
maxHeight: number;
} | null>(null);
const { width: windowWidth, height: windowHeight } = useWindowSize();
const primaryRipple = useRippleHandlers(disabled);
const menuRipple = useRippleHandlers(disabled);
const MENU_GAP = 16;
const EDGE_PAD = 16;
const updateMenuPosition = useCallback(() => {
const anchor = menuSegmentRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const menuEl = menuRef.current;
const menuWidth = menuEl?.offsetWidth ?? 220;
const menuHeight = menuEl?.offsetHeight ?? 320;
const anchorCenterX = rect.left + rect.width / 2;
let left: number;
let top: number | undefined;
let bottom: number | undefined;
let maxHeight: number;
const vw = window.innerWidth;
const vh = window.innerHeight;
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
const fitsBelow = menuHeight <= availableBelow;
const fitsAbove = menuHeight <= availableAbove;
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
if (placeAbove) {
bottom = vh - (rect.top - MENU_GAP);
maxHeight = Math.max(100, availableAbove);
} else {
top = rect.bottom + MENU_GAP;
maxHeight = Math.max(100, availableBelow);
}
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
left = EDGE_PAD;
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
left = vw - menuWidth - EDGE_PAD;
} else {
left = anchorCenterX - menuWidth / 2;
}
setMenuPosition({ top, bottom, left, maxHeight });
}, []);
const closeMenu = useCallback(() => {
onMenuOpen(false);
setIsExiting(true);
}, [onMenuOpen]);
useEffect(() => {
if (!open && !isExiting) {
setMenuPosition(null);
return;
}
if (!open) return;
updateMenuPosition();
window.addEventListener("scroll", updateMenuPosition, true);
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
const target = event.target as Node | null;
if (!target) return;
if (rootRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
closeMenu();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") closeMenu();
}
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
document.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("scroll", updateMenuPosition, true);
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
useEffect(() => {
if (!open) setIsExiting(true);
}, [open]);
useLayoutEffect(() => {
if (open && menuRef.current) {
updateMenuPosition();
}
}, [open, updateMenuPosition]);
const handlePrimaryClick = () => {
if (disabled) {
return;
}
onPrimaryClick?.();
};
const handleMenuToggle = () => {
if (disabled) return;
if (open) closeMenu();
else onMenuOpen(true);
};
const variantClass =
variant === "tonal"
? styles.variantTonal
: variant === "outlined"
? styles.variantOutlined
: variant === "elevated"
? styles.variantElevated
: styles.variantFilled;
const renderIcon = () => {
if (!icon) {
return null;
}
if (typeof icon === "string") {
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
}
return <span className={styles.leadingIconIcon}>{icon}</span>;
};
const rootClasses = [
styles.splitButton,
variantClass,
disabled ? styles.disabled : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
ref={rootRef}
className={rootClasses}
data-open={open ? "true" : "false"}
aria-disabled={disabled ? "true" : "false"}
>
<button
type="button"
className={styles.primarySegment}
onClick={handlePrimaryClick}
onPointerDown={primaryRipple.onPointerDown}
onPointerEnter={primaryRipple.onPointerEnter}
onPointerLeave={primaryRipple.onPointerLeave}
disabled={disabled}
>
<MaterialRipple ref={primaryRipple.rippleRef} />
<span className={styles.primaryContent}>
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
<span className={styles.label}>{text}</span>
</span>
</button>
<button
ref={menuSegmentRef}
type="button"
className={styles.menuSegment}
onClick={handleMenuToggle}
onPointerDown={menuRipple.onPointerDown}
onPointerEnter={menuRipple.onPointerEnter}
onPointerLeave={menuRipple.onPointerLeave}
disabled={disabled}
aria-haspopup="menu"
aria-expanded={open}
aria-label={menuAriaLabel}
>
<MaterialRipple ref={menuRipple.rippleRef} />
<span className={styles.menuIcon}>
<MaterialIcon name="expand_more" />
</span>
</button>
{(open || isExiting) &&
menuPosition &&
createPortal(
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
{open && (
<motion.div
key="menu"
ref={menuRef}
className={styles.menu}
style={{
position: "fixed",
...(menuPosition.bottom != null
? { bottom: menuPosition.bottom }
: { top: menuPosition.top }),
left: menuPosition.left,
maxHeight: menuPosition.maxHeight,
overflowY: "auto",
}}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{menu}
</motion.div>
)}
</AnimatePresence>,
document.body
)}
</div>
);
}
@@ -0,0 +1,231 @@
@use "../../../css/material" as *;
$height: 40px;
$trailing-width: 48px; // 12 + 22 + 14 per spec
$between-space: 2px;
$outer-radius: calc(#{$height} / 2); // 20px
$inner-radius: 4px;
$inner-radius-hovered: 12px;
.splitButton {
display: inline-flex;
align-items: stretch;
position: relative;
border-radius: $outer-radius;
font-family: inherit;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.1px;
background-color: transparent;
color: $color-dark-on-primary;
isolation: isolate;
&.disabled {
opacity: 0.38;
pointer-events: none;
}
.primarySegment,
.menuSegment {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
background-color: transparent;
color: inherit;
padding: 0;
min-height: $height;
cursor: pointer;
font: inherit;
box-sizing: border-box;
overflow: hidden;
&:focus-visible {
outline: 2px solid $color-dark-primary;
outline-offset: 2px;
}
mdui-ripple {
position: absolute;
inset: 0;
pointer-events: none;
}
}
.primarySegment {
border-top-left-radius: $outer-radius;
border-bottom-left-radius: $outer-radius;
border-top-right-radius: $inner-radius;
border-bottom-right-radius: $inner-radius;
padding-inline: 16px 12px;
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
@media (hover: hover) {
&:hover {
border-top-right-radius: $inner-radius-hovered;
border-bottom-right-radius: $inner-radius-hovered;
}
}
&:active {
border-top-right-radius: $inner-radius-hovered;
border-bottom-right-radius: $inner-radius-hovered;
}
.primaryContent {
display: inline-flex;
align-items: center;
gap: 8px;
pointer-events: none;
user-select: none;
.leadingIcon {
display: inline-flex;
align-items: center;
justify-content: center;
.leadingIconIcon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
width: 20px;
height: 20px;
}
}
.label {
white-space: nowrap;
}
}
}
.menuSegment {
width: $trailing-width;
border-top-right-radius: $outer-radius;
border-bottom-right-radius: $outer-radius;
border-top-left-radius: $inner-radius;
border-bottom-left-radius: $inner-radius;
margin-left: $between-space;
padding-inline: 12px 14px;
transition:
border-top-left-radius 0.18s ease-out,
border-bottom-left-radius 0.18s ease-out,
padding-inline 0.18s ease-out;
@media (hover: hover) {
&:hover {
border-top-left-radius: $inner-radius-hovered;
border-bottom-left-radius: $inner-radius-hovered;
}
}
&:active {
border-top-left-radius: $inner-radius-hovered;
border-bottom-left-radius: $inner-radius-hovered;
}
.menuIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
font-size: 22px;
transition: transform 0.18s ease-out;
pointer-events: none;
user-select: none;
mdui-icon {
width: inherit;
height: inherit;
font-size: inherit;
}
}
}
&[data-open="true"] .menuSegment {
$size: calc($trailing-width / 2);
border-radius: $size;
padding-inline: 13px 13px;
.menuIcon {
transform: rotate(-180deg);
}
}
&.variantFilled {
.primarySegment, .menuSegment {
background-color: $color-dark-primary;
color: $color-dark-on-primary;
border: none;
}
}
&.variantTonal {
.primarySegment, .menuSegment {
background-color: $color-dark-primary-container;
color: $color-dark-on-primary-container;
border: none;
}
}
&.variantOutlined {
.primarySegment, .menuSegment {
border: 1px solid rgba($color-dark-outline, 0.8);
border: none;
background-color: transparent;
color: $color-dark-on-surface;
}
}
&.variantElevated {
.primarySegment, .menuSegment {
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.3),
0 1px 2px rgba(0, 0, 0, 0.15);
background-color: $color-dark-surface-container-low;
color: $color-dark-on-surface;
}
}
}
$menu-padding: 8px;
.menu {
padding: $menu-padding;
min-width: 220px;
border-radius: 16px;
background-color: rgba($color-dark-surface-container-high, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 100000000;
// Custom slim semi-transparent scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba($color-dark-on-surface, 0.25);
border-radius: 2px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba($color-dark-on-surface, 0.4);
}
scrollbar-width: thin;
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
mdui-list {
padding: 0;
}
}
+18
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 879 KiB

+4
View File
@@ -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

+3
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

+3
View File
@@ -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

+6
View File
@@ -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

+45 -74
View File
@@ -1,5 +1,5 @@
import { AuthContainer } from "./Auth"; import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
@@ -8,18 +8,23 @@ import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react"; import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss"; import styles from "./auth.module.scss";
const MIN_HEIGHT = 400;
const slideVariants: Variants = { const slideVariants: Variants = {
enter: (direction: number) => ({ enter: (direction: number) => ({
x: direction > 0 ? 300 : -300, x: direction > 0 ? 300 : -300,
opacity: 0 opacity: 0,
y: 0 // Ensure no vertical movement
}), }),
center: { center: {
x: 0, x: 0,
opacity: 1 opacity: 1,
y: 0 // Ensure no vertical movement
}, },
exit: (direction: number) => ({ exit: (direction: number) => ({
x: direction > 0 ? -300 : 300, x: direction > 0 ? -300 : 300,
opacity: 0 opacity: 0,
y: 0 // Ensure no vertical movement
}) })
}; };
@@ -45,96 +50,65 @@ export default function AuthPage() {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const loginFormRef = useRef<HTMLDivElement>(null); const loginFormRef = useRef<HTMLDivElement>(null);
const registerFormRef = useRef<HTMLDivElement>(null); const registerFormRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto"); const [containerHeight, setContainerHeight] = useState<number>(400);
const [isTransitioning, setIsTransitioning] = useState(false);
const currentMode = searchParams.get("mode") || "login"; const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false);
useEffect(() => { useEffect(() => {
if (prevMode.current !== currentMode) { if (prevMode.current !== currentMode) {
setDirection(currentMode === "register" ? 1 : -1); setDirection(currentMode === "register" ? 1 : -1);
prevMode.current = currentMode; prevMode.current = currentMode;
enteringElementRef.current = currentMode as "login" | "register"; setIsTransitioning(true);
} }
}, [currentMode]); }, [currentMode]);
const measureActiveHeight = useCallback(() => { // Setup ResizeObserver to watch for content changes
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current; useEffect(() => {
if (activeComponent) { const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
const height = activeComponent.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
}, [currentMode, loginFormRef, registerFormRef]);
useLayoutEffect(() => { if (activeRef.current) {
if (!effectActivated) { const resizeObserver = new ResizeObserver((entries) => {
setEffectActivated(true); for (const entry of entries) {
return; const height = entry.contentRect.height;
} if (height > 0) {
setContainerHeight(Math.max(height, MIN_HEIGHT));
// 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();
}); });
});
return () => { resizeObserver.observe(activeRef.current);
cancelAnimationFrame(rafId1);
if (rafId2 !== null) { // Initial measurement
cancelAnimationFrame(rafId2); const initialHeight = activeRef.current.scrollHeight;
if (initialHeight > 0) {
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
} }
};
return () => {
resizeObserver.disconnect();
};
}
}, [currentMode]); }, [currentMode]);
function switchMode(newMode: "login" | "register") { function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true }); navigate(`/auth?mode=${newMode}`, { replace: true });
} }
function handleAnimationComplete( function handleAnimationComplete() {
currentMode: "login" | "register", setIsTransitioning(false);
mode: "login" | "register",
enteringElementRef: RefObject<"login" | "register" | null>,
formRef: React.RefObject<HTMLDivElement | null>,
setContainerHeight: (height: number) => void
) {
return () => {
if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (formRef.current && currentMode === mode) {
const height = formRef.current.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
});
});
}
}
} }
return ( return (
<AuthContainer> <AuthContainer>
<div <div
ref={containerRef} ref={containerRef}
style={{ style={{
position: "relative", position: "relative",
width: "100%", width: "100%",
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`, height: `${containerHeight}px`,
transition: "height 0.3s ease" transition: isTransitioning ? "height 0.3s ease" : "none"
}}
onAnimationStart={() => {
}}
onAnimationEnd={() => {
setContainerHeight("auto");
}} }}
> >
<AnimatePresence mode="sync" custom={direction}> <AnimatePresence mode="sync" custom={direction}>
@@ -148,11 +122,8 @@ export default function AuthPage() {
animate="center" animate="center"
exit="exit" exit="exit"
transition={slideTransition} transition={slideTransition}
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)} onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper} className={styles.formWrapper}
style={{
position: containerHeight === "auto" ? "relative" : "absolute"
}}
> >
<LoginForm onSwitchMode={() => switchMode("register")} /> <LoginForm onSwitchMode={() => switchMode("register")} />
</motion.div> </motion.div>
@@ -166,7 +137,7 @@ export default function AuthPage() {
animate="center" animate="center"
exit="exit" exit="exit"
transition={slideTransition} transition={slideTransition}
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)} onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper} className={styles.formWrapper}
> >
<RegisterForm onSwitchMode={() => switchMode("login")} /> <RegisterForm onSwitchMode={() => switchMode("login")} />
+6
View File
@@ -98,6 +98,12 @@
top: 0; top: 0;
left: 0; left: 0;
&.relative {
position: relative;
top: auto;
left: auto;
}
.authHeader { .authHeader {
margin: 0; margin: 0;
padding: 24px; padding: 24px;
+1 -2
View File
@@ -6,8 +6,7 @@ import { parse } from "marked";
import { escape as escapeHtml } from "he"; import { escape as escapeHtml } from "he";
import { useEffect, useState, useRef, useMemo } from "react"; import { useEffect, useState, useRef, useMemo } from "react";
import api from "@/core/api"; import api from "@/core/api";
import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol";
import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric";
import { useUserStore } from "@/state/user"; import { useUserStore } from "@/state/user";
import { useProfileStore } from "@/state/profile"; import { useProfileStore } from "@/state/profile";
import { StatusBadge } from "@/core/components/StatusBadge"; import { StatusBadge } from "@/core/components/StatusBadge";
@@ -1,28 +1,29 @@
import { MaterialButton } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
import styles from "./download-app.module.scss"; import styles from "./download-app.module.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
return ( return (
<div className={styles.downloadAppScreen}> <div className={styles.downloadAppScreen}>
<div> <div className={styles.downloadAppCard}>
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1> <h1>Скачайте приложение</h1>
<p> <p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому Этот сайт не предназначен для работы на маленьких экранах.
вам нужно скачать приложение мессенджера. Выберите вашу платформу:
</p> </p>
<div className={styles.downloadAppButtons}>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest"> <a href="/download?os=android" className={styles.downloadAppBtn}>
<MaterialButton>Скачать на GitHub</MaterialButton> <MaterialIcon name="android" />
</a> Android
</a>
<a href="/download?os=ios" className={styles.downloadAppBtn}>
<MaterialIcon name="phone_iphone" />
iOS
</a>
</div>
<p> <p>
Если возникнут сложности или есть вопросы, нажмите кнопку! <a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p> </p>
<a href="https://t.me/denis0001-dev">
<MaterialButton>Написать в поддержку</MaterialButton>
</a>
</div> </div>
</div> </div>
) );
} }
@@ -1,9 +1,70 @@
@use "../../css/material" as *;
.downloadAppScreen { .downloadAppScreen {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-width: 100vw; min-width: 100vw;
min-height: 100vh; min-height: 100vh;
padding: 32px; padding: 24px;
background-color: $color-dark-surface;
}
.downloadAppCard {
max-width: 400px;
padding: 40px 32px;
background: rgba($color-dark-surface-container, 0.6);
border: 1px solid rgba($color-dark-outline, 0.3);
border-radius: 24px;
text-align: center;
h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px;
color: $color-dark-on-surface;
}
p {
color: $color-dark-on-surface-variant;
margin: 0 0 24px;
font-size: 16px;
a {
color: $color-dark-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.downloadAppButtons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 24px;
}
.downloadAppBtn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: rgba($color-dark-primary, 0.2);
border: 1px solid rgba($color-dark-primary, 0.5);
border-radius: 12px;
color: $color-dark-on-surface;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background: rgba($color-dark-primary, 0.3);
border-color: $color-dark-primary;
}
} }
+112
View File
@@ -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>
Если появится запрос &quot;Разрешить установку из неизвестных источников&quot; дайте
разрешение для браузера, из которого вы скачивали APK.
</li>
<li>
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
нажмите &quot;Подробнее&quot; &quot;Всё равно установить&quot; (или аналогичную кнопку).
</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 для&nbsp;
<span className={styles.osName}>{osInfo.label}</span>
</p>
</div>
</div>
{renderInstructions(os)}
</div>
</StyledDialog>
);
}
+95
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+207 -214
View File
@@ -1,228 +1,236 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user"; import { useRef, useState, type ReactNode } from "react";
import styles from "./home.module.scss"; import styles from "@/pages/home/home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon } from "@/utils/material"; import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
import dmScreenshot from "@/images/screenshots/dm.png";
import windowsIcon from "@/images/windows.svg";
import linuxIcon from "@/images/linux.svg";
import macIcon from "@/images/mac.svg";
import { HomeHeader } from "@/pages/home/HomeHeader";
import { HomeFooter } from "@/pages/home/HomeFooter";
import { SplitButton } from "@/core/components/SplitButton";
import { DownloadDialog } from "@/pages/home/DownloadDialog";
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
function GitHubLink({ children }: { children: React.ReactNode }) { interface FeatureSectionProps {
return ( title: ReactNode;
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a> children: ReactNode;
); screenshot: string;
right?: boolean;
} }
function SupportLink({ children }: { children: React.ReactNode }) { function FeatureSection({
return ( title,
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a> children,
screenshot,
right = false,
}: FeatureSectionProps) {
const featureText = (
<div className={styles.featureText}>
<div className={styles.featureTitle}>{title}</div>
<div className={styles.featureDesc}>{children}</div>
</div>
); );
const featureScreenshot = (
<div className={styles.featureScreenshotOuter}>
<div className={styles.featureScreenshotGlow} />
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
</div>
)
return (
<div className={`${styles.featureContainer}`}>
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
</div>
)
} }
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen(); const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
function handleGetStarted() { const [dialogOpen, setDialogOpen] = useState(false);
if (isMobile) { const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
navigate("/download-app"); const [menuOpen, setMenuOpen] = useState(false);
} else if (isLoggedIn) { const downloadSectionRef = useRef<HTMLElement>(null);
navigate("/chat");
} else { const scrollToDownload = () => {
navigate("/login"); const section = downloadSectionRef.current;
const header = document.querySelector<HTMLElement>("[data-home-header]");
if (!section) return;
const headerHeight = header?.getBoundingClientRect().height ?? 0;
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
window.scrollTo({ top: targetY, behavior: "smooth" });
};
const triggerDownload = (os: DownloadOs): boolean => {
if (typeof document === "undefined") {
return false;
} }
}
const openBtn = ( setDialogOs(os);
<MaterialButton variant="filled" onClick={handleGetStarted}> setDialogOpen(true);
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
</MaterialButton> const link = document.createElement("a");
); link.href = `/api/download/${os}`;
link.download = "";
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return true;
};
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
const detectedOs = detectOs();
if (os === detectedOs) return "filled";
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
return "outlined";
};
return ( return (
<div className={styles.homepage}> <div className={styles.homepage}>
<header className={styles.homepageHeader}> <HomeHeader onScrollToDownload={scrollToDownload} />
<div className={styles.container}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<h1>FromChat</h1>
<span className={styles.tagline}>100% открытый мессенджер</span>
</div>
<nav className={styles.headerNav}>
<GitHubLink>
<MaterialButton variant="text">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text">Поддержка</MaterialButton>
</SupportLink>
{openBtn}
</nav>
</div>
</div>
</header>
<main> <main>
<section className={styles.hero}> <section className={styles.title}>
<div className={styles.container}> <div className={styles.titleLogoWrapper}>
<div className={styles.heroContent}> <div className={styles.titleLogo} />
<h2 className={styles.heroTitle}> </div>
Безопасный мессенджер с открытым исходным кодом <div className={styles.titleContent}>FromChat</div>
</h2> <div className={styles.titleDesc}>
<p className={styles.heroDescription}> 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
FromChat это полностью открытый мессенджер с end-to-end шифрованием, </div>
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу. <div className={styles.titleButtons}>
</p> {isMobile ? null : (
<div className={styles.heroActions}> <MaterialButton
{openBtn} variant="filled"
{!isMobile && ( onClick={() => navigate("/auth?mode=login")}
<MaterialButton icon="devices"
variant="outlined" >
onClick={() => navigate("/register")}> Открыть веб-версию
Зарегистрироваться </MaterialButton>
</MaterialButton> )}
)} <SplitButton
</div> variant={isMobile ? "filled" : "tonal"}
</div> text="Скачать приложение"
<div className={styles.heroVisual}> icon="download"
<div className={styles.chatPreview}> onPrimaryClick={() => triggerDownload(detectOs())}
<div className={styles.chatWindow}> menuOpen={menuOpen}
<div className={styles.chatHeader}> onMenuOpen={setMenuOpen}
<div className={styles.chatTitle}>Общий чат</div> menu={(
<div className={styles.onlineIndicator}></div> <MaterialList>
</div> {ALL_OS.map((os) => (
<div className={styles.chatMessages}> <MaterialListItem
<div className={`${styles.message} ${styles.received}`}> key={os}
<div className={styles.messageAvatar}>А</div> icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
<div className={styles.messageContent}> headline={OS_CONFIG[os].label}
<div className={styles.messageText}>Привет! Как дела?</div> rounded
<div className={styles.messageTime}>14:30</div> onClick={() => {
</div> if (triggerDownload(os)) setMenuOpen(false);
</div> }}
<div className={`${styles.message} ${styles.sent}`}> >
<div className={styles.messageContent}> {["windows", "linux", "macos"].includes(os) && (
<div className={styles.messageText}>Всё отлично! А у тебя как?</div> <span
<div className={styles.messageTime}>14:32</div> slot="icon"
</div> className={styles.menuCustomIcon}
</div> style={{
<div className={`${styles.message} ${styles.received}`}> "--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
<div className={styles.messageAvatar}>Б</div> } as React.CSSProperties}
<div className={styles.messageContent}> />
<div className={styles.messageText}>Отправляю файл 📎</div> )}
<div className={styles.messageTime}>14:35</div> </MaterialListItem>
</div> ))}
</div> </MaterialList>
</div> )}
</div> />
</div>
</div>
</div> </div>
</section> </section>
<section className={styles.features}> <section className={styles.features}>
<div className={styles.container}> <FeatureSection
<h3 className={styles.sectionTitle}>Возможности</h3> title={<>Общий чат</>}
<div className={styles.featuresGrid}> screenshot={generalChatScreenshot}
<div className={styles.featureCard}> right
<div className={styles.featureIcon}> >
<MaterialIcon name="security" /> Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
</div> </FeatureSection>
<h4>End-to-End Шифрование</h4> <FeatureSection
<p> title={<>Личные сообщения</>}
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM. screenshot={dmScreenshot}>
Только вы и получатель можете прочитать сообщения. Общайтесь с одним человеком в личной переписке.
</p> </FeatureSection>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="code" />
</div>
<h4>100% открытый код</h4>
<p>
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
внести изменения или развернуть свой сервер.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="attach_file" />
</div>
<h4>Обмен Файлами</h4>
<p>
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="notifications" />
</div>
<h4>Уведомления</h4>
<p>
Получайте push-уведомления в браузере и настольном приложении.
Никогда не пропустите важное сообщение.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="edit" />
</div>
<h4>Редактирование</h4>
<p>
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
для лучшего контекста общения.
</p>
</div>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="computer" />
</div>
<h4>Кроссплатформенность</h4>
<p>
Работает в браузере и как настольное приложение для Windows,
macOS и Linux. Единый интерфейс везде.
</p>
</div>
</div>
</div>
</section> </section>
<section className={styles.download}> <section ref={downloadSectionRef} className={styles.download}>
<div className={styles.container}> <div className={styles.container}>
<div className={styles.downloadContent}> <div className={styles.downloadContent}>
<h3>Скачайте приложение</h3> <h3>Скачайте приложение</h3>
<p> <p>
Для лучшего опыта используйте настольное приложение с поддержкой Настольное приложение с уведомлениями и автономной работой
уведомлений и автономной работы. или мобильное приложение для Android и iOS.
</p> </p>
<div className={styles.downloadButtons}> <table className={styles.downloadTable}>
{!isMobile ? ( <thead>
<> <tr>
<a <th>Платформа</th>
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml" <th>Описание</th>
target="_blank" <th />
rel="noopener noreferrer" </tr>
> </thead>
<MaterialButton variant="filled"> <tbody>
<MaterialIcon name="download" slot="icon" /> {ALL_OS.map((os) => (
Скачать для ПК <tr key={os}>
</MaterialButton> <td className={styles.downloadTableOs}>
</a> <span className={styles.downloadTableOsContent}>
<MaterialButton variant="outlined" onClick={() => navigate("/login")}> {["windows", "linux", "macos"].includes(os) ? (
<MaterialIcon name="language" slot="icon" /> <span
Веб-версия className={styles.tableOsIcon}
</MaterialButton> style={{
</> "--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
) : ( } as React.CSSProperties}
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}> />
Скачать приложение ) : (
</MaterialButton> <MaterialIcon
)} name={OS_CONFIG[os].icon}
</div> 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>
</div> </div>
</section> </section>
@@ -232,7 +240,8 @@ export default function HomePage() {
<div className={styles.ctaContent}> <div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3> <h3>Готовы начать общение?</h3>
<p> <p>
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами. Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
или звоните всё бесплатно и с открытым кодом.
</p> </p>
<div className={styles.ctaActions}> <div className={styles.ctaActions}>
{isMobile ? ( {isMobile ? (
@@ -259,24 +268,8 @@ export default function HomePage() {
</section> </section>
</main> </main>
<footer className={styles.homepageFooter}> <DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
<div className={styles.container}> <HomeFooter onScrollToDownload={scrollToDownload} />
<div className={styles.footerContent}>
<div className={styles.footerSection}>
<h4>Ссылки</h4>
<GitHubLink>GitHub</GitHubLink>
<SupportLink>Поддержка</SupportLink>
</div>
<div className={styles.footerSection}>
<h4>Лицензия</h4>
<p>GPL-3.0</p>
</div>
</div>
<div className={styles.footerBottom}>
<p>&copy; 2025 FromChat. Сделано программистом denis0001-dev с для свободы общения.</p>
</div>
</div>
</footer>
</div> </div>
); );
} }
+11
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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 };
+56
View File
@@ -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";
}
+26
View File
@@ -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);
}
+70 -1
View File
@@ -17,18 +17,21 @@ import 'mdui/components/button';
import 'mdui/components/text-field'; import 'mdui/components/text-field';
import 'mdui/components/button-icon'; import 'mdui/components/button-icon';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/ripple';
import 'mdui/components/chip'; import 'mdui/components/chip';
import 'mdui/components/badge'; import 'mdui/components/badge';
import "mdui/mdui.css"; import "mdui/mdui.css";
import 'mdui/components/circular-progress'; import 'mdui/components/circular-progress';
import { useCallback, useRef } from "react";
import { setColorScheme } from 'mdui/functions/setColorScheme'; import { setColorScheme } from 'mdui/functions/setColorScheme';
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react'; import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react';
import type { TextField } from 'mdui/components/text-field'; import type { TextField } from 'mdui/components/text-field';
import type { Switch } from 'mdui/components/switch'; import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types'; import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button'; import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon'; import type { ButtonIcon } from 'mdui/components/button-icon';
import type { Ripple } from 'mdui/components/ripple';
import type { Icon } from 'mdui/components/icon'; import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab'; import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs'; import type { Tabs } from 'mdui/components/tabs';
@@ -58,6 +61,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>; export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>; export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>; export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIRipple = Override<HTMLElement, Ripple>;
export type MDUIFab = Override<HTMLElement, Fab>; export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>; export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>; export type MDUITab = Override<HTMLElement, Tab>;
@@ -140,4 +144,69 @@ export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>; export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) { export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} /> return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
}
export type MaterialRippleProps = NoChildren<BasePropCustomization<"mdui-ripple", MDUIRipple>>;
export function MaterialRipple(props: MaterialRippleProps) {
return <mdui-ripple {...props as ComponentProps<"mdui-ripple">} />
}
/**
* Returns pointer handlers that forward press and hover events to an mdui-ripple element.
* Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button).
*
* @example
* const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled);
* <button {...rippleHandlers}>
* <MaterialRipple ref={rippleRef} />
* ...
* </button>
*/
export function useRippleHandlers(
disabled = false
): {
rippleRef: RefObject<MDUIRipple | null>;
onPointerDown: (e: React.PointerEvent) => void;
onPointerEnter: (e: React.PointerEvent) => void;
onPointerLeave: (e: React.PointerEvent) => void;
} {
const rippleRef = useRef<MDUIRipple | null>(null);
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.button !== 0) return;
const ripple = rippleRef.current;
if (!ripple?.startPress) return;
ripple.startPress(e.nativeEvent);
const btn = e.currentTarget as HTMLElement;
const endPress = () => {
ripple.endPress?.();
btn.removeEventListener("pointerup", endPress);
btn.removeEventListener("pointercancel", endPress);
btn.removeEventListener("pointerleave", endPress);
};
btn.addEventListener("pointerup", endPress);
btn.addEventListener("pointercancel", endPress);
btn.addEventListener("pointerleave", endPress);
},
[disabled]
);
const onPointerEnter = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.pointerType !== "mouse") return;
rippleRef.current?.startHover?.();
},
[disabled]
);
const onPointerLeave = useCallback(
(e: React.PointerEvent) => {
if (disabled || e.pointerType !== "mouse") return;
rippleRef.current?.endHover?.();
},
[disabled]
);
return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave };
} }
+6
View File
@@ -8,6 +8,12 @@ declare global {
interface SyntheticEvent<T = Element, E = Event> { interface SyntheticEvent<T = Element, E = Event> {
target: EventTarget & T; target: EventTarget & T;
} }
namespace JSX {
interface IntrinsicElements {
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
}
}
} }
// Augment DOM event listeners to provide typed target for ALL elements // Augment DOM event listeners to provide typed target for ALL elements
+6 -5
View File
@@ -14,10 +14,11 @@
"noEmit": true, "noEmit": true,
/* Path mapping */ /* Path mapping */
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"],
}, "@fromchat/protocol": ["./packages/fromchat-protocol/src"]
},
/* Linting */ /* Linting */
"strict": true, "strict": true,
@@ -31,6 +32,6 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react"
}, },
"include": ["src", "electron.d.ts"], "include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"],
"exclude": ["**/__*/**", "__*"] "exclude": ["**/__*/**", "__*"]
} }
+3 -1
View File
@@ -70,7 +70,8 @@ export default defineConfig({
plugins: plugins, plugins: plugins,
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src") "@": path.resolve(__dirname, "./src"),
"@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts")
} }
}, },
server: { server: {
@@ -89,6 +90,7 @@ export default defineConfig({
}, },
appType: "spa", appType: "spa",
optimizeDeps: { optimizeDeps: {
exclude: ["@fromchat/protocol"],
esbuildOptions: { esbuildOptions: {
target: "es2022" target: "es2022"
} }
+9 -4
View File
@@ -31,7 +31,7 @@
"install:pussh": "bash ./scripts/install:pussh.sh", "install:pussh": "bash ./scripts/install:pussh.sh",
"prepare": "husky", "prepare": "husky",
"generate:env": "bash ./scripts/generate:env.sh", "generate:env": "bash ./scripts/generate:env.sh",
"deploy": "bash ./scripts/deploy.sh" "deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh"
}, },
"files": [ "files": [
"frontend/build/electron" "frontend/build/electron"
@@ -51,9 +51,10 @@
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"baseline-browser-mapping": "^2.9.11",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^11.0.0",
"electron": "^38.1.2", "electron": "^39.2.7",
"husky": "^9.1.7", "husky": "^9.1.7",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup-plugin-visualizer": "^6.0.4", "rollup-plugin-visualizer": "^6.0.4",
@@ -67,12 +68,16 @@
"vite-plugin-html": "^3.2.2", "vite-plugin-html": "^3.2.2",
"vite-plugin-sass-dts": "^1.3.34" "vite-plugin-sass-dts": "^1.3.34"
}, },
"workspaces": [
"frontend/packages/fromchat-protocol"
],
"dependencies": { "dependencies": {
"@fromchat/protocol": "workspace:*",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
"he": "^1.2.0", "he": "^1.2.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"marked": "^16.3.0", "marked": "^17.0.1",
"mdui": "^2.1.4", "mdui": "^2.1.4",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.1.1", "react": "^19.1.1",
+172 -38
View File
@@ -113,56 +113,115 @@ fi
step "Authentication" step "Authentication"
SSH_KEY_FILE="$HOME/.ssh/id_rsa" SSH_KEY_FILE="$HOME/.ssh/id_rsa"
SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub"
# Ensure ssh-agent is running # Ensure ssh-agent is running
if [ -z "$SSH_AUTH_SOCK" ]; then if [ -z "$SSH_AUTH_SOCK" ]; then
eval "$(ssh-agent -s)" > /dev/null 2>&1 eval "$(ssh-agent -s)" > /dev/null 2>&1
fi fi
# Add SSH key to agent if not already loaded # Check if SSH key exists
if [ -f "$SSH_KEY_FILE" ]; then if [ ! -f "$SSH_KEY_FILE" ]; then
# Check if key is already loaded error "SSH key not found at $SSH_KEY_FILE"
KEY_LOADED=false echo " Please generate an SSH key pair first:"
if ssh-add -l > /dev/null 2>&1; then echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'"
# Check if this specific key is loaded by trying to match the public key exit 1
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"
fi fi
# Test SSH connection once to cache the key (this will prompt for passphrase if needed) # Add SSH key to agent if not already loaded
ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true 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 AUTHENTICATION
# ============================================================================ # ============================================================================
SUDO_PASSWORD="" SUDO_PASSWORD=""
while true; do # If SSH password was provided, try using it for sudo first
substep "Sudo password: " -n if [ -n "$SSH_PASSWORD" ]; then
SUDO_PASSWORD=$(read_password) if echo "$SSH_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then
SUDO_PASSWORD="$SSH_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 export SUDO_PASSWORD
break
else
echo -n " " && error "Invalid password, please try again"
fi 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 # BUILD PHASE
@@ -329,6 +388,7 @@ for SERVICE in $SERVICES; do
echo "" echo ""
else else
error "Build failed for $SERVICE" error "Build failed for $SERVICE"
exit 1
fi fi
done done
@@ -347,15 +407,89 @@ if ! docker pussh --help > /dev/null 2>&1; then
echo " Install: npm run install:pussh" echo " Install: npm run install:pussh"
fi fi
# Detect images # Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true)) cd "$DEPLOYMENT_DIR"
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
IMAGES=()
for S in $COMPOSE_SERVICES; do
# Try to read explicit image: field from the compose config for this service
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
grep -A5 "^[[:space:]]*${S}:" | \
grep -m1 "image:" || true)
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
IMAGES+=("$IMAGE_FROM_COMPOSE")
else
# If service has a build section (we built it above), use the tag pattern used during build
TAG="${PROJECT_NAME}-${S}:latest"
# Only include the tag if the image exists locally (avoid pushing unrelated images)
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
IMAGES+=("$TAG")
fi
fi
done
# Deduplicate while preserving order
if [ ${#IMAGES[@]} -gt 0 ]; then
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
fi
# Verify that all built images are among the detected images to be pushed.
# This prevents accidentally pushing unrelated images.
BUILT_COUNT=${#BUILT_IMAGES[@]}
MATCHING_BUILT=0
MISSING_FROM_DETECTED=()
for BI in "${BUILT_IMAGES[@]}"; do
found=false
for DI in "${IMAGES[@]}"; do
if [ "$BI" = "$DI" ]; then
found=true
break
fi
done
if [ "$found" = true ]; then
MATCHING_BUILT=$((MATCHING_BUILT + 1))
else
MISSING_FROM_DETECTED+=("$BI")
fi
done
# Also list detected images that weren't built locally (these are likely external images)
NOT_BUILT_DETECTED=()
for DI in "${IMAGES[@]}"; do
built=false
for BI in "${BUILT_IMAGES[@]}"; do
if [ "$DI" = "$BI" ]; then
built=true
break
fi
done
if [ "$built" = false ]; then
NOT_BUILT_DETECTED+=("$DI")
fi
done
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
fi
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
fi
echo "Aborting to avoid pushing incorrect images."
exit 1
fi
if [ ${#IMAGES[@]} -eq 0 ]; then if [ ${#IMAGES[@]} -eq 0 ]; then
error "No ${PROJECT_NAME} images found" error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
fi fi
# Pre-pull unregistry image if needed # Pre-pull unregistry image if needed
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1" UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry"
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
substep "Pulling unregistry image (one-time setup)..." substep "Pulling unregistry image (one-time setup)..."
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
+2
View File
@@ -9,4 +9,6 @@ JWT_SECRET="$(openssl rand -base64 32)"
TURN_USERNAME=<set> TURN_USERNAME=<set>
TURN_SECRET=<set> TURN_SECRET=<set>
DEPLOYMENT_SERVER=<set> DEPLOYMENT_SERVER=<set>
FIREBASE_CERT=<set>
RELEASES_TOKEN=<set>
EOF EOF