Compare commits
18 Commits
@@ -576,4 +576,7 @@ backend/alembic/**
|
|||||||
!backend/alembic/script.py.mako
|
!backend/alembic/script.py.mako
|
||||||
!frontend/src/css/lib
|
!frontend/src/css/lib
|
||||||
**/*.module.scss.d.ts
|
**/*.module.scss.d.ts
|
||||||
|
|
||||||
.cursor/plans
|
.cursor/plans
|
||||||
|
tmp
|
||||||
|
compliance_keypair.txt
|
||||||
@@ -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>.
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
# format, relative to the token %(here)s which refers to the location of this
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
# ini file
|
# ini file
|
||||||
script_location = alembic
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
# Uncomment the line below if you want the files to be prepended with date and time
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
@@ -84,7 +84,7 @@ path_separator = os
|
|||||||
# database URL. This is consumed by the user-maintained env.py script only.
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
# other means of configuring database URLs may be customized within the env.py
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
# file.
|
# file.
|
||||||
# sqlalchemy.url is set dynamically from DATABASE_URL environment variable
|
sqlalchemy.url = sqlite:///./data/database.db
|
||||||
|
|
||||||
|
|
||||||
[post_write_hooks]
|
[post_write_hooks]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if config.config_file_name is not None:
|
|||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
from backend.shared.models import Base
|
from models import Base
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
# other values from the config, defined by the needs of env.py,
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
|||||||
@@ -1,43 +1,103 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
from fastapi import FastAPI, Request, HTTPException
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import httpx
|
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||||
import logging
|
import logging
|
||||||
# Gateway doesn't need direct model access - it's a stateless proxy
|
from models import User
|
||||||
# Gateway doesn't need constants - it's a stateless proxy
|
from constants import OWNER_USERNAME
|
||||||
from backend.shared.utils import get_client_ip
|
from utils import get_client_ip
|
||||||
|
|
||||||
# Gateway doesn't need database access - it's a stateless proxy
|
from db import POOL_CONFIG, SessionLocal
|
||||||
from backend.logging_config import access_logger # noqa: F401 - ensure loggers configured
|
from logging_config import access_logger # noqa: F401 - ensure loggers configured
|
||||||
from backend.security.audit import log_access
|
from security.audit import log_access
|
||||||
from backend.security.rate_limit import limiter
|
from security.rate_limit import limiter
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
|
||||||
# Service URL mapping for routing
|
|
||||||
SERVICE_URLS = {
|
|
||||||
"account": os.getenv("ACCOUNT_SERVICE_URL", "http://account_service:8302"),
|
|
||||||
"profile": os.getenv("PROFILE_SERVICE_URL", "http://profile_service:8303"),
|
|
||||||
"devices": os.getenv("DEVICE_SERVICE_URL", "http://device_service:8304"),
|
|
||||||
"messaging": os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305"),
|
|
||||||
"push": os.getenv("PUSH_SERVICE_URL", "http://push_service:8306"),
|
|
||||||
"webrtc": os.getenv("WEBRTC_SERVICE_URL", "http://webrtc_service:8307"),
|
|
||||||
"moderation": os.getenv("MODERATION_SERVICE_URL", "http://moderation_service:8308"),
|
|
||||||
}
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Gateway is a stateless proxy - no database operations or background tasks needed
|
# Startup - run migration in subprocess to avoid logging interference
|
||||||
logger.info("Gateway proxy service initialized - routing to microservices")
|
try:
|
||||||
|
logger.info("Starting database migration check...")
|
||||||
|
# Run migration in a separate process
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-c",
|
||||||
|
"import sys; sys.path.append('.'); from migration import run_migrations; run_migrations()"
|
||||||
|
],
|
||||||
|
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to run database migrations: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
owner = db.query(User).filter(User.id == 1).first()
|
||||||
|
if owner and not owner.verified:
|
||||||
|
owner.verified = True
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Owner user '{OWNER_USERNAME}' has been verified")
|
||||||
|
elif owner and owner.verified:
|
||||||
|
logger.info(f"Owner user '{OWNER_USERNAME}' is already verified")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Owner user '{OWNER_USERNAME}' not found")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ensure owner verification: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)",
|
||||||
|
POOL_CONFIG["pool_size"],
|
||||||
|
POOL_CONFIG["max_overflow"],
|
||||||
|
POOL_CONFIG["pool_timeout"],
|
||||||
|
POOL_CONFIG["pool_recycle"],
|
||||||
|
POOL_CONFIG["pool_pre_ping"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start the messaging cleanup task
|
||||||
|
try:
|
||||||
|
from routes.messaging import messagingManager
|
||||||
|
messagingManager.start_cleanup_task()
|
||||||
|
logger.info("Messaging cleanup task started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start messaging cleanup task: {e}")
|
||||||
|
|
||||||
|
# Reset all rate limits on startup to ensure clean state
|
||||||
|
# This prevents rate limits from persisting across restarts
|
||||||
|
try:
|
||||||
|
from security.rate_limit import reset_all_rate_limits
|
||||||
|
cleared = reset_all_rate_limits()
|
||||||
|
if cleared > 0:
|
||||||
|
logger.info(f"Cleared {cleared} rate limit entries on startup")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to reset rate limits on startup: {e}")
|
||||||
|
|
||||||
|
# Start the rate limit cleanup task
|
||||||
|
try:
|
||||||
|
from security.rate_limit import start_rate_limit_cleanup_task
|
||||||
|
cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task())
|
||||||
|
logger.info("Rate limit cleanup task started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start rate limit cleanup task: {e}")
|
||||||
|
cleanup_task = None
|
||||||
|
|
||||||
yield
|
yield
|
||||||
logger.info("Gateway proxy service shutting down.")
|
|
||||||
|
# Shutdown - cancel cleanup task if it exists
|
||||||
|
if cleanup_task:
|
||||||
|
cleanup_task.cancel()
|
||||||
|
try:
|
||||||
|
await cleanup_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Инициализация FastAPI
|
# Инициализация FastAPI
|
||||||
app = FastAPI(title="FromChat", lifespan=lifespan)
|
app = FastAPI(title="FromChat", lifespan=lifespan)
|
||||||
@@ -108,87 +168,12 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Common API endpoints - route to appropriate services (defined first for priority)
|
# Routes
|
||||||
@app.api_route("/login", methods=["POST"])
|
app.include_router(account.router)
|
||||||
async def login(request: Request):
|
app.include_router(messaging.router)
|
||||||
"""Login endpoint - routes to account service."""
|
app.include_router(profile.router)
|
||||||
return await _proxy_to_service("account", "login", request)
|
app.include_router(push.router, prefix="/push")
|
||||||
|
app.include_router(webrtc.router, prefix="/webrtc")
|
||||||
@app.api_route("/register", methods=["POST"])
|
app.include_router(devices.router, prefix="/devices")
|
||||||
async def register(request: Request):
|
app.include_router(moderation.router)
|
||||||
"""Register endpoint - routes to account service."""
|
app.include_router(download.router)
|
||||||
return await _proxy_to_service("account", "register", request)
|
|
||||||
|
|
||||||
|
|
||||||
# API routes - route to appropriate microservices
|
|
||||||
@app.api_route("/account/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_account(path: str, request: Request):
|
|
||||||
"""Proxy account service requests."""
|
|
||||||
return await _proxy_to_service("account", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/profile/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_profile(path: str, request: Request):
|
|
||||||
"""Proxy profile service requests."""
|
|
||||||
return await _proxy_to_service("profile", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/devices/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_devices(path: str, request: Request):
|
|
||||||
"""Proxy device service requests."""
|
|
||||||
return await _proxy_to_service("devices", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/messaging/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_messaging(path: str, request: Request):
|
|
||||||
"""Proxy messaging service requests."""
|
|
||||||
return await _proxy_to_service("messaging", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/push/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_push(path: str, request: Request):
|
|
||||||
"""Proxy push service requests."""
|
|
||||||
return await _proxy_to_service("push", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/webrtc/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_webrtc(path: str, request: Request):
|
|
||||||
"""Proxy WebRTC service requests."""
|
|
||||||
return await _proxy_to_service("webrtc", path, request)
|
|
||||||
|
|
||||||
@app.api_route("/moderation/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
|
||||||
async def proxy_moderation(path: str, request: Request):
|
|
||||||
"""Proxy moderation service requests."""
|
|
||||||
return await _proxy_to_service("moderation", path, request)
|
|
||||||
|
|
||||||
async def _proxy_to_service(service: str, path: str, request: Request):
|
|
||||||
"""Helper function to proxy requests to microservices."""
|
|
||||||
from fastapi.responses import Response
|
|
||||||
|
|
||||||
service_url = SERVICE_URLS[service]
|
|
||||||
target_url = f"{service_url}/{service}/{path}"
|
|
||||||
|
|
||||||
# Get request body
|
|
||||||
body = await request.body()
|
|
||||||
|
|
||||||
# Prepare headers (remove host header)
|
|
||||||
headers = dict(request.headers)
|
|
||||||
headers.pop("host", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=target_url,
|
|
||||||
headers=headers,
|
|
||||||
content=body,
|
|
||||||
params=request.query_params,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Return response with the same status code and content
|
|
||||||
content = response.content
|
|
||||||
return Response(
|
|
||||||
content=content,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers={"content-type": response.headers.get("content-type", "application/json")}
|
|
||||||
)
|
|
||||||
except httpx.RequestError as exc:
|
|
||||||
logging.error(f"Error communicating with {service} service: {exc}")
|
|
||||||
raise HTTPException(status_code=503, detail=f"Service {service} unavailable")
|
|
||||||
|
|
||||||
# Routes are handled by the catch-all proxy above
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from backend.shared.constants import *
|
from constants import *
|
||||||
from backend.shared.db import *
|
from db import *
|
||||||
from backend.shared.models import *
|
from models import *
|
||||||
from backend.shared.validation import *
|
from validation import *
|
||||||
from backend.shared.utils import *
|
from utils import *
|
||||||
from backend.shared.dependencies import *
|
from dependencies import *
|
||||||
from app import *
|
from app import *
|
||||||
@@ -8,7 +8,7 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from alembic.runtime.migration import MigrationContext
|
from alembic.runtime.migration import MigrationContext
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from backend.shared.constants import DATABASE_URL
|
from constants import DATABASE_URL
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -19,9 +19,6 @@ def run_migrations():
|
|||||||
This function will upgrade the database to the latest migration.
|
This function will upgrade the database to the latest migration.
|
||||||
Fully automated - handles all scenarios automatically.
|
Fully automated - handles all scenarios automatically.
|
||||||
"""
|
"""
|
||||||
# Get the directory where this script is located
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# FIRST: Check if database has any application tables (excluding alembic_version)
|
# FIRST: Check if database has any application tables (excluding alembic_version)
|
||||||
engine = create_engine(DATABASE_URL)
|
engine = create_engine(DATABASE_URL)
|
||||||
@@ -34,11 +31,12 @@ def run_migrations():
|
|||||||
# If no application tables exist, create them directly from models
|
# If no application tables exist, create them directly from models
|
||||||
if not existing_tables:
|
if not existing_tables:
|
||||||
logger.info("No application tables found. Creating all tables directly from models...")
|
logger.info("No application tables found. Creating all tables directly from models...")
|
||||||
from backend.shared.models import Base
|
from models import Base
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
logger.info("All tables created successfully from models.")
|
logger.info("All tables created successfully from models.")
|
||||||
|
|
||||||
# Note: Problematic migrations are now cleaned up during Docker build
|
# Get the directory where this script is located
|
||||||
|
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"))
|
||||||
@@ -57,13 +55,6 @@ def run_migrations():
|
|||||||
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
||||||
|
|
||||||
# If no migration files exist after cleanup, create initial migration
|
|
||||||
if not migration_files:
|
|
||||||
logger.info("No migration files found after cleanup. Creating initial migration...")
|
|
||||||
command.revision(alembic_cfg, autogenerate=True, message="Initial migration")
|
|
||||||
migration_files = [f for f in os.listdir(versions_dir) if f.endswith('.py') and not f.startswith('__')]
|
|
||||||
logger.info(f"Created {len(migration_files)} initial migration(s)")
|
|
||||||
|
|
||||||
if not migration_files:
|
if not migration_files:
|
||||||
logger.info("No migration files found. Creating initial migration...")
|
logger.info("No migration files found. Creating initial migration...")
|
||||||
# Check if database exists and has tables
|
# Check if database exists and has tables
|
||||||
@@ -147,11 +138,7 @@ def run_migrations():
|
|||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully.")
|
logger.info("Database migrations completed successfully.")
|
||||||
except Exception as upgrade_error:
|
except Exception as upgrade_error:
|
||||||
error_msg = str(upgrade_error)
|
if "Can't locate revision identified by 'direct_creation'" in str(upgrade_error):
|
||||||
# Handle PostgreSQL "already exists" errors gracefully
|
|
||||||
if "already exists" in error_msg.lower() or "relation" in error_msg.lower() and "exists" in error_msg.lower():
|
|
||||||
pass
|
|
||||||
elif "Can't locate revision identified by 'direct_creation'" in error_msg:
|
|
||||||
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
logger.info("Found 'direct_creation' revision - resetting migration state...")
|
||||||
# Clear the alembic_version table and start fresh
|
# Clear the alembic_version table and start fresh
|
||||||
engine = create_engine(DATABASE_URL)
|
engine = create_engine(DATABASE_URL)
|
||||||
@@ -196,7 +183,6 @@ def run_migrations():
|
|||||||
# Try upgrade again
|
# Try upgrade again
|
||||||
command.upgrade(alembic_cfg, "head")
|
command.upgrade(alembic_cfg, "head")
|
||||||
logger.info("Database migrations completed successfully after reset.")
|
logger.info("Database migrations completed successfully after reset.")
|
||||||
# Note: Index-related errors are now prevented by Docker build cleanup
|
|
||||||
else:
|
else:
|
||||||
raise upgrade_error
|
raise upgrade_error
|
||||||
|
|
||||||
@@ -303,7 +289,7 @@ def _populate_migration_file(migration_path):
|
|||||||
|
|
||||||
def _generate_migration_from_models():
|
def _generate_migration_from_models():
|
||||||
"""Generate migration content dynamically from SQLAlchemy models."""
|
"""Generate migration content dynamically from SQLAlchemy models."""
|
||||||
from backend.shared.models import Base
|
from models import Base
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -488,12 +474,10 @@ def _get_column_type(column):
|
|||||||
|
|
||||||
def _create_database_directly():
|
def _create_database_directly():
|
||||||
"""Fallback method: create database directly using SQLAlchemy."""
|
"""Fallback method: create database directly using SQLAlchemy."""
|
||||||
from backend.shared.models import Base
|
from models import Base
|
||||||
from backend.shared.db import get_engine
|
from db import engine
|
||||||
from sqlalchemy import text, inspect
|
from sqlalchemy import text, inspect
|
||||||
|
|
||||||
engine = get_engine()
|
|
||||||
|
|
||||||
# Check existing tables and update schema
|
# Check existing tables and update schema
|
||||||
with engine.connect() as connection:
|
with engine.connect() as connection:
|
||||||
inspector = inspect(connection)
|
inspector = inspect(connection)
|
||||||
@@ -549,9 +533,6 @@ def _create_database_directly():
|
|||||||
# Table doesn't exist, create it
|
# Table doesn't exist, create it
|
||||||
logger.info(f"Creating table {table_name}")
|
logger.info(f"Creating table {table_name}")
|
||||||
|
|
||||||
# Create alembic_version table manually
|
|
||||||
engine = get_engine(DATABASE_URL)
|
|
||||||
with engine.connect() as connection:
|
|
||||||
# Create alembic_version table manually
|
# Create alembic_version table manually
|
||||||
connection.execute(text("""
|
connection.execute(text("""
|
||||||
CREATE TABLE IF NOT EXISTS alembic_version (
|
CREATE TABLE IF NOT EXISTS alembic_version (
|
||||||
@@ -575,9 +556,9 @@ def _create_database_directly():
|
|||||||
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
revision_match = re.search(r"revision: str = '([^']+)'", content)
|
||||||
if revision_match:
|
if revision_match:
|
||||||
revision_id = revision_match.group(1)
|
revision_id = revision_match.group(1)
|
||||||
connection.execute(text(f"INSERT INTO alembic_version (version_num) VALUES ('{revision_id}') ON CONFLICT DO NOTHING"))
|
connection.execute(text(f"INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('{revision_id}')"))
|
||||||
else:
|
else:
|
||||||
connection.execute(text("INSERT INTO alembic_version (version_num) VALUES ('direct_creation') ON CONFLICT DO NOTHING"))
|
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||||
else:
|
else:
|
||||||
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
connection.execute(text("INSERT OR IGNORE INTO alembic_version (version_num) VALUES ('direct_creation')"))
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import os
|
|||||||
from typing import List, Optional
|
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 backend.shared.models import PushSubscription, User, Message, DMEnvelope, FcmToken
|
from models import PushSubscription, User, Message, DMEnvelope
|
||||||
|
from models import FcmToken
|
||||||
import firebase_admin
|
import firebase_admin
|
||||||
from firebase_admin import credentials as firebase_credentials
|
from firebase_admin import credentials as firebase_credentials
|
||||||
from firebase_admin import messaging as firebase_messaging
|
from firebase_admin import messaging as firebase_messaging
|
||||||
@@ -2,7 +2,6 @@ PyJWT>=2.8.0
|
|||||||
fastapi[standard]>=0.116.1
|
fastapi[standard]>=0.116.1
|
||||||
pydantic>=2.11.7
|
pydantic>=2.11.7
|
||||||
sqlalchemy>=2.0.43
|
sqlalchemy>=2.0.43
|
||||||
psycopg2-binary>=2.9.9
|
|
||||||
bcrypt>=4.3.0
|
bcrypt>=4.3.0
|
||||||
websockets>=15.0.1
|
websockets>=15.0.1
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
@@ -16,4 +15,3 @@ 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
|
firebase_admin>=7.1.0
|
||||||
PyNaCl>=1.5.0
|
|
||||||
@@ -8,16 +8,16 @@ import uuid
|
|||||||
from user_agents import parse as parse_ua
|
from user_agents import parse as parse_ua
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
from backend.shared.constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from backend.shared.dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from backend.shared.models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup
|
from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession
|
||||||
from backend.shared.utils import create_token, get_password_hash, verify_password, get_client_ip
|
from utils import create_token, get_password_hash, verify_password, get_client_ip
|
||||||
from backend.shared.validation import is_valid_password, is_valid_username, is_valid_display_name
|
from validation import is_valid_password, is_valid_username, is_valid_display_name
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.security.audit import log_security
|
from security.audit import log_security
|
||||||
from backend.security.profanity import contains_profanity
|
from security.profanity import contains_profanity
|
||||||
from backend.security.rate_limit import rate_limit_per_ip
|
from security.rate_limit import rate_limit_per_ip
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
_FAILED_ATTEMPT_WINDOW_SECONDS = 300
|
||||||
@@ -47,16 +47,16 @@ def convert_user(user: User) -> dict:
|
|||||||
"id": user.id,
|
"id": user.id,
|
||||||
"created_at": user.created_at.isoformat(),
|
"created_at": user.created_at.isoformat(),
|
||||||
"last_seen": user.last_seen.isoformat(),
|
"last_seen": user.last_seen.isoformat(),
|
||||||
"online": user.is_online,
|
"online": user.online,
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"profile_picture": user.avatar_url,
|
"profile_picture": user.profile_picture,
|
||||||
"bio": user.bio,
|
"bio": user.bio,
|
||||||
"admin": _is_admin(user),
|
"admin": _is_admin(user),
|
||||||
"verified": user.verified,
|
"verified": user.verified,
|
||||||
"suspended": user.suspended,
|
"suspended": user.suspended or False,
|
||||||
"suspension_reason": user.suspension_reason,
|
"suspension_reason": user.suspension_reason,
|
||||||
"deleted": user.deleted
|
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/check_auth")
|
@router.get("/check_auth")
|
||||||
@@ -77,7 +77,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
|||||||
|
|
||||||
user = db.query(User).filter(User.username == username).first()
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
|
||||||
if not user or not verify_password(login_request.password.strip(), user.hashed_password):
|
if not user or not verify_password(login_request.password.strip(), user.password_hash):
|
||||||
log_security(
|
log_security(
|
||||||
"login_failed",
|
"login_failed",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
@@ -112,9 +112,30 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
|||||||
detail="Неверное имя пользователя или пароль"
|
detail="Неверное имя пользователя или пароль"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate session ID for JWT (device session will be created on first device service access)
|
# Create device session and embed into JWT
|
||||||
|
raw_ua = request.headers.get("user-agent")
|
||||||
|
device_name = request.headers.get("x-device-name")
|
||||||
|
ua = parse_ua(raw_ua or "")
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
device = DeviceSession(
|
||||||
|
user_id=user.id,
|
||||||
|
raw_user_agent=raw_ua,
|
||||||
|
device_name=device_name,
|
||||||
|
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||||
|
os_name=(ua.os.family or None),
|
||||||
|
os_version=(ua.os.version_string or None),
|
||||||
|
browser_name=(ua.browser.family or None),
|
||||||
|
browser_version=(ua.browser.version_string or None),
|
||||||
|
brand=(ua.device.brand or None),
|
||||||
|
model=(ua.device.model or None),
|
||||||
|
session_id=session_id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
last_seen=datetime.now(),
|
||||||
|
revoked=False,
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
|
||||||
user.online = True
|
user.online = True
|
||||||
user.last_seen = datetime.now()
|
user.last_seen = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -127,19 +148,15 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g
|
|||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
_reset_failed_logins(identifier)
|
_reset_failed_logins(identifier)
|
||||||
|
|
||||||
# Parse user agent for logging
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
device_type = "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"
|
|
||||||
|
|
||||||
log_security(
|
log_security(
|
||||||
"login_success",
|
"login_success",
|
||||||
username=user.username,
|
username=user.username,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
ip=client_ip,
|
ip=client_ip,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
device=device_type,
|
device=device.device_type,
|
||||||
os=ua.os.family,
|
os=device.os_name,
|
||||||
browser=ua.browser.family,
|
browser=device.browser_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -213,9 +230,8 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
new_user = User(
|
new_user = User(
|
||||||
username=username,
|
username=username,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
hashed_password=hashed_password,
|
password_hash=hashed_password,
|
||||||
salt="", # Not used since bcrypt includes salt in hash
|
online=True,
|
||||||
is_online=True,
|
|
||||||
last_seen=datetime.now(),
|
last_seen=datetime.now(),
|
||||||
verified=is_owner
|
verified=is_owner
|
||||||
)
|
)
|
||||||
@@ -224,13 +240,32 @@ def register(request: Request, register_request: RegisterRequest, db: Session =
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_user)
|
db.refresh(new_user)
|
||||||
|
|
||||||
# Generate a temporary session ID for the token (device session will be created on first device service access)
|
# Create initial device session
|
||||||
|
raw_ua = request.headers.get("user-agent")
|
||||||
|
device_name = request.headers.get("x-device-name")
|
||||||
|
ua = parse_ua(raw_ua or "")
|
||||||
session_id = uuid.uuid4().hex
|
session_id = uuid.uuid4().hex
|
||||||
|
device = DeviceSession(
|
||||||
|
user_id=new_user.id,
|
||||||
|
raw_user_agent=raw_ua,
|
||||||
|
device_name=device_name,
|
||||||
|
device_type=("mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "bot" if ua.is_bot else "desktop"),
|
||||||
|
os_name=(ua.os.family or None),
|
||||||
|
os_version=(ua.os.version_string or None),
|
||||||
|
browser_name=(ua.browser.family or None),
|
||||||
|
browser_version=(ua.browser.version_string or None),
|
||||||
|
brand=(ua.device.brand or None),
|
||||||
|
model=(ua.device.model or None),
|
||||||
|
session_id=session_id,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
last_seen=datetime.now(),
|
||||||
|
revoked=False,
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
token = create_token(new_user.id, new_user.username, session_id)
|
token = create_token(new_user.id, new_user.username, session_id)
|
||||||
|
|
||||||
# Parse user agent for logging
|
|
||||||
raw_ua = request.headers.get("user-agent")
|
|
||||||
ua = parse_ua(raw_ua or "")
|
|
||||||
os_name = ua.os.family or "Unknown OS"
|
os_name = ua.os.family or "Unknown OS"
|
||||||
if ua.os.version_string:
|
if ua.os.version_string:
|
||||||
os_name = f"{os_name} {ua.os.version_string}"
|
os_name = f"{os_name} {ua.os.version_string}"
|
||||||
@@ -321,7 +356,7 @@ def delete_user_as_owner(
|
|||||||
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
|
||||||
from backend.shared.models import Message # local import to avoid circular
|
from models import Message # local import to avoid circular
|
||||||
db.query(Message).filter(Message.user_id == user.id).delete()
|
db.query(Message).filter(Message.user_id == user.id).delete()
|
||||||
|
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
@@ -345,14 +380,14 @@ def logout(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
# Revoke current session - TODO: Move to device service
|
# Revoke current session
|
||||||
# from backend.shared.utils import verify_token as _verify_token
|
from utils import verify_token as _verify_token
|
||||||
# payload = _verify_token(credentials.credentials)
|
payload = _verify_token(credentials.credentials)
|
||||||
# if payload and payload.get("session_id"):
|
if payload and payload.get("session_id"):
|
||||||
# db.query(DeviceSession).filter(
|
db.query(DeviceSession).filter(
|
||||||
# DeviceSession.user_id == current_user.id,
|
DeviceSession.user_id == current_user.id,
|
||||||
# DeviceSession.session_id == payload["session_id"],
|
DeviceSession.session_id == payload["session_id"],
|
||||||
# ).update({DeviceSession.revoked: True})
|
).update({DeviceSession.revoked: True})
|
||||||
|
|
||||||
current_user.online = False
|
current_user.online = False
|
||||||
current_user.last_seen = datetime.now()
|
current_user.last_seen = datetime.now()
|
||||||
@@ -364,7 +399,7 @@ def logout(
|
|||||||
username=current_user.username,
|
username=current_user.username,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
ip=client_ip,
|
ip=client_ip,
|
||||||
session_id=None, # TODO: Get session_id from device service
|
session_id=payload.get("session_id") if payload else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -383,25 +418,25 @@ def change_password(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
# Verify current derived password against stored hash
|
# Verify current derived password against stored hash
|
||||||
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.hashed_password):
|
if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash):
|
||||||
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
raise HTTPException(status_code=401, detail="Текущий пароль неверный")
|
||||||
|
|
||||||
# Update password hash to hash of new derived password
|
# Update password hash to hash of new derived password
|
||||||
current_user.hashed_password = get_password_hash(password_request.newPasswordDerived.strip())
|
current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip())
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Optionally revoke all other sessions, keeping the current one - TODO: Move to device service
|
# Optionally revoke all other sessions, keeping the current one
|
||||||
# if password_request.logoutAllExceptCurrent:
|
if password_request.logoutAllExceptCurrent:
|
||||||
# from backend.shared.utils import verify_token as _verify_token
|
from utils import verify_token as _verify_token
|
||||||
# payload = _verify_token(credentials.credentials)
|
payload = _verify_token(credentials.credentials)
|
||||||
# if not payload:
|
if not payload:
|
||||||
# raise HTTPException(status_code=401, detail="Invalid token")
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
# current_session_id = payload.get("session_id")
|
current_session_id = payload.get("session_id")
|
||||||
# db.query(DeviceSession).filter(
|
db.query(DeviceSession).filter(
|
||||||
# DeviceSession.user_id == current_user.id,
|
DeviceSession.user_id == current_user.id,
|
||||||
# DeviceSession.session_id != current_session_id,
|
DeviceSession.session_id != current_session_id,
|
||||||
# ).update({DeviceSession.revoked: True})
|
).update({DeviceSession.revoked: True})
|
||||||
# db.commit()
|
db.commit()
|
||||||
|
|
||||||
client_ip = get_client_ip(request)
|
client_ip = get_client_ip(request)
|
||||||
log_security(
|
log_security(
|
||||||
@@ -461,7 +496,7 @@ async def _delete_user_data(user: User, db: Session):
|
|||||||
user.deleted = True
|
user.deleted = True
|
||||||
user.display_name = f"Deleted User #{user_id}"
|
user.display_name = f"Deleted User #{user_id}"
|
||||||
user.bio = None
|
user.bio = None
|
||||||
user.hashed_password = ""
|
user.password_hash = ""
|
||||||
user.username = f"deleted_{user_id}"
|
user.username = f"deleted_{user_id}"
|
||||||
user.profile_picture = None
|
user.profile_picture = None
|
||||||
user.last_seen = None # Clear last seen timestamp
|
user.last_seen = None # Clear last seen timestamp
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from datetime import datetime
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.shared.dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from backend.shared.models import User, DeviceSession
|
from models import User, DeviceSession
|
||||||
from backend.shared.utils import verify_token
|
from utils import verify_token
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,30 +16,29 @@ from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisco
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.shared.dependencies import get_current_user, get_db
|
from dependencies import get_current_user, get_db
|
||||||
from backend.shared.utils import convert_user
|
from .account import convert_user
|
||||||
from backend.shared.constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from backend.shared.models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog
|
||||||
import os
|
from push_service import push_service
|
||||||
import httpx
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from better_profanity import profanity as _bp
|
from better_profanity import profanity as _bp
|
||||||
from backend.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 backend.security.profanity import contains_profanity
|
from security.profanity import contains_profanity
|
||||||
from backend.security.rate_limit import rate_limit_per_ip
|
from security.rate_limit import rate_limit_per_ip
|
||||||
from backend.services.messaging.files.websocket.utils import authenticate_user
|
from websocket.utils import authenticate_user
|
||||||
|
|
||||||
from backend.shared.models import FcmToken
|
from models import FcmToken
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
MAX_TOTAL_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB
|
||||||
|
|
||||||
FILES_BASE_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "files"
|
FILES_BASE_DIR = Path("data/uploads/files")
|
||||||
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
FILES_NORMAL_DIR = FILES_BASE_DIR / "normal"
|
||||||
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted"
|
||||||
|
|
||||||
@@ -394,16 +393,7 @@ async def _send_message_internal(
|
|||||||
|
|
||||||
# Send push notifications for public messages
|
# Send push notifications for public messages
|
||||||
try:
|
try:
|
||||||
push_service_url = os.getenv("PUSH_SERVICE_URL", "http://push_service:8306")
|
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{push_service_url}/push/send-public-notification",
|
|
||||||
json={
|
|
||||||
"message_id": new_message.id,
|
|
||||||
"exclude_user_id": current_user.id
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
|
||||||
|
|
||||||
@@ -1292,7 +1282,7 @@ class MessaggingSocketManager:
|
|||||||
self.ws_subscriptions[websocket] = set()
|
self.ws_subscriptions[websocket] = set()
|
||||||
|
|
||||||
# Import here to avoid circular import
|
# Import here to avoid circular import
|
||||||
from backend.services.messaging.files.websocket.handlers import handler_registry
|
from websocket.handlers import handler_registry
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -1571,21 +1561,3 @@ async def get_file_encrypted(filename: str, current_user: User = Depends(get_cur
|
|||||||
raise HTTPException(500)
|
raise HTTPException(500)
|
||||||
|
|
||||||
return FileResponse(str(path))
|
return FileResponse(str(path))
|
||||||
|
|
||||||
|
|
||||||
class SendSuspensionRequest(BaseModel):
|
|
||||||
user_id: int
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-suspension")
|
|
||||||
async def send_suspension_to_user(
|
|
||||||
request: SendSuspensionRequest,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Send suspension message to user via WebSocket (called by profile service)"""
|
|
||||||
try:
|
|
||||||
await messagingManager.send_suspension_to_user(request.user_id, request.reason)
|
|
||||||
return {"status": "success"}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
@@ -2,12 +2,12 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from backend.shared.constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
from backend.shared.dependencies import get_current_user
|
from dependencies import get_current_user
|
||||||
from backend.shared.models import User
|
from models import User
|
||||||
from backend.security.audit import log_security
|
from security.audit import log_security
|
||||||
from backend.security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist
|
||||||
from backend.security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits
|
||||||
|
|
||||||
|
|
||||||
class BlocklistUpdateRequest(BaseModel):
|
class BlocklistUpdateRequest(BaseModel):
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ import uuid
|
|||||||
import io
|
import io
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from backend.shared.dependencies import get_db, get_current_user
|
from dependencies import get_db, get_current_user
|
||||||
from backend.shared.models import User, UpdateBioRequest, UserProfileResponse
|
from models import User, UpdateBioRequest, UserProfileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from backend.shared.validation import is_valid_username, is_valid_display_name
|
from validation import is_valid_username, is_valid_display_name
|
||||||
from backend.shared.similarity import is_user_similar_to_verified
|
from similarity import is_user_similar_to_verified
|
||||||
import os
|
from .messaging import messagingManager
|
||||||
import httpx
|
from security.audit import log_security
|
||||||
from backend.security.audit import log_security
|
from security.profanity import contains_profanity
|
||||||
from backend.security.profanity import contains_profanity
|
from security.rate_limit import rate_limit_per_ip
|
||||||
from backend.security.rate_limit import rate_limit_per_ip
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class UpdateProfileRequest(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
# Create uploads directory if it doesn't exist
|
# Create uploads directory if it doesn't exist
|
||||||
PROFILE_PICTURES_DIR = Path(__file__).resolve().parent.parent / "data" / "uploads" / "pfp"
|
PROFILE_PICTURES_DIR = Path("data/uploads/pfp")
|
||||||
|
|
||||||
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True)
|
||||||
|
|
||||||
@@ -480,16 +479,7 @@ async def suspend_user(
|
|||||||
|
|
||||||
# Send WebSocket suspension message
|
# Send WebSocket suspension message
|
||||||
try:
|
try:
|
||||||
messaging_service_url = os.getenv("MESSAGING_SERVICE_URL", "http://messaging_service:8305")
|
await messagingManager.send_suspension_to_user(user_id, request.reason)
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{messaging_service_url}/messaging/send-suspension",
|
|
||||||
json={
|
|
||||||
"user_id": user_id,
|
|
||||||
"reason": request.reason
|
|
||||||
}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but don't fail the request
|
# Log error but don't fail the request
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from dependencies import get_current_user, get_db
|
||||||
from backend.shared.dependencies import get_current_user, get_db
|
from models import User, PushSubscriptionRequest
|
||||||
from backend.shared.models import User, PushSubscriptionRequest
|
from push_service import push_service
|
||||||
from backend.services.push.files import push_service
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
class SendPublicNotificationRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
exclude_user_id: int
|
|
||||||
|
|
||||||
@router.post("/subscribe")
|
@router.post("/subscribe")
|
||||||
async def subscribe_to_push_notifications(
|
async def subscribe_to_push_notifications(
|
||||||
request: PushSubscriptionRequest,
|
request: PushSubscriptionRequest,
|
||||||
@@ -49,21 +44,3 @@ async def unsubscribe_from_push_notifications(
|
|||||||
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.post("/send-public-notification")
|
|
||||||
async def send_public_message_notification(
|
|
||||||
request: SendPublicNotificationRequest,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Send push notification for public message (called by messaging service)"""
|
|
||||||
try:
|
|
||||||
# Get the message from database
|
|
||||||
from backend.shared.models import Message
|
|
||||||
message = db.query(Message).filter(Message.id == request.message_id).first()
|
|
||||||
if not message:
|
|
||||||
raise HTTPException(status_code=404, detail="Message not found")
|
|
||||||
|
|
||||||
await push_service.send_public_message_notification(db, message, exclude_user_id=request.exclude_user_id)
|
|
||||||
return {"status": "success"}
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import hmac
|
|||||||
import hashlib
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from backend.shared.dependencies import get_current_user
|
from dependencies import get_current_user
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Local development server that runs all services in a single FastAPI application.
|
|
||||||
This provides the same monolithic experience as before, but with microservice separation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
import uvicorn
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Import service routers
|
|
||||||
from backend.routes.account import router as account_router
|
|
||||||
from backend.routes.profile import router as profile_router
|
|
||||||
from backend.routes.devices import router as device_router
|
|
||||||
from backend.routes.messaging import router as messaging_router
|
|
||||||
from backend.routes.push import router as push_router
|
|
||||||
from backend.routes.webrtc import router as webrtc_router
|
|
||||||
from backend.routes.moderation import router as moderation_router
|
|
||||||
|
|
||||||
# Import security modules
|
|
||||||
from security.audit import log_access
|
|
||||||
from security.rate_limit import limiter
|
|
||||||
from slowapi.middleware import SlowAPIMiddleware
|
|
||||||
|
|
||||||
# Create main FastAPI app
|
|
||||||
app = FastAPI(title="FromChat Local Development")
|
|
||||||
|
|
||||||
# Add rate limiting middleware
|
|
||||||
app.state.limiter = limiter
|
|
||||||
app.add_middleware(SlowAPIMiddleware)
|
|
||||||
|
|
||||||
# CORS middleware
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=[
|
|
||||||
"https://fromchat.ru",
|
|
||||||
"https://beta.fromchat.ru",
|
|
||||||
"https://www.fromchat.ru",
|
|
||||||
"http://127.0.0.1:8301",
|
|
||||||
"http://127.0.0.1:8300",
|
|
||||||
"http://localhost:8301",
|
|
||||||
"http://localhost:8300",
|
|
||||||
],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mount service routers with appropriate prefixes
|
|
||||||
app.include_router(account_router, prefix="/account")
|
|
||||||
app.include_router(profile_router, prefix="/profile")
|
|
||||||
app.include_router(device_router, prefix="/devices")
|
|
||||||
app.include_router(messaging_router, prefix="/messaging")
|
|
||||||
app.include_router(push_router, prefix="/push")
|
|
||||||
app.include_router(webrtc_router, prefix="/webrtc")
|
|
||||||
app.include_router(moderation_router, prefix="/moderation")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Run the server
|
|
||||||
port = int(os.getenv("PORT", "8301"))
|
|
||||||
host = os.getenv("HOST", "127.0.0.1")
|
|
||||||
|
|
||||||
print(f"Starting FromChat local development server on {host}:{port}")
|
|
||||||
print("Available services:")
|
|
||||||
print(" - Account: http://127.0.0.1:8301/account/")
|
|
||||||
print(" - Profile: http://127.0.0.1:8301/profile/")
|
|
||||||
print(" - Devices: http://127.0.0.1:8301/devices/")
|
|
||||||
print(" - Messaging: http://127.0.0.1:8301/messaging/")
|
|
||||||
print(" - Push: http://127.0.0.1:8301/push/")
|
|
||||||
print(" - WebRTC: http://127.0.0.1:8301/webrtc/")
|
|
||||||
print(" - Moderation: http://127.0.0.1:8301/moderation/")
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"run_local:app",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=True,
|
|
||||||
reload_dirs=["backend"]
|
|
||||||
)
|
|
||||||
@@ -4,7 +4,7 @@ import logging
|
|||||||
from html import unescape
|
from html import unescape
|
||||||
from typing import Any, Callable, Dict, List
|
from typing import Any, Callable, Dict, List
|
||||||
|
|
||||||
from backend.logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
from logging_config import access_logger, dm_logger, public_chat_logger, security_logger
|
||||||
|
|
||||||
|
|
||||||
def _clean_username(username: Any) -> str:
|
def _clean_username(username: Any) -> str:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Iterable, List, Set, Tuple
|
|||||||
|
|
||||||
from better_profanity import Profanity
|
from better_profanity import Profanity
|
||||||
|
|
||||||
BLOCKLIST_PATH = Path(__file__).resolve().parent.parent / "data" / "profanity" / "blocklist.json"
|
BLOCKLIST_PATH = Path("data/profanity/blocklist.json")
|
||||||
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
_CUSTOM_RU_TERMS: Set[str] = {
|
_CUSTOM_RU_TERMS: Set[str] = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from fastapi import Request
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
from backend.shared.utils import get_client_ip
|
from utils import get_client_ip
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
# Account Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/account.py /app/backend/routes/account.py
|
|
||||||
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=account
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.account import router as account_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Account Service")
|
|
||||||
app.include_router(account_router, prefix="/account")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Device Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
|
||||||
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=device
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.devices import router as device_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Device Service")
|
|
||||||
app.include_router(device_router, prefix="/devices")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Gateway Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/app.py /app/backend/app.py
|
|
||||||
COPY backend/main.py /app/backend/main.py
|
|
||||||
COPY backend/dependencies.py /app/backend/dependencies.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=gateway
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Gateway service - runs the main gateway app from backend/app.py
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
from backend.app import app
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8300)))
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Messaging Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
|
||||||
COPY backend/websocket /app/backend/websocket/
|
|
||||||
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=messaging
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.messaging import router as messaging_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Messaging Service")
|
|
||||||
app.include_router(messaging_router, prefix="/messaging")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Migration Runner Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy migration files
|
|
||||||
COPY backend/alembic /app/backend/alembic/
|
|
||||||
COPY backend/migration.py /app/backend/migration.py
|
|
||||||
|
|
||||||
# Copy migration runner
|
|
||||||
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=migration_runner
|
|
||||||
|
|
||||||
# Override entrypoint to run migrations
|
|
||||||
ENTRYPOINT ["python", "-m", "backend.services.migration_runner.main"]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Migration runner service - executes database migrations and exits.
|
|
||||||
This service runs Alembic migrations against PostgreSQL and terminates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from alembic import command
|
|
||||||
from alembic.config import Config
|
|
||||||
|
|
||||||
def run_migrations():
|
|
||||||
"""Run Alembic migrations."""
|
|
||||||
print("Starting database migrations...")
|
|
||||||
|
|
||||||
# Change to backend directory to run migrations
|
|
||||||
backend_dir = Path(__file__).parent.parent.parent
|
|
||||||
os.chdir(backend_dir)
|
|
||||||
|
|
||||||
# Ensure shared models are imported for alembic
|
|
||||||
import backend.shared.models
|
|
||||||
|
|
||||||
# Set DATABASE_URL from environment if not set
|
|
||||||
db_url = os.getenv("DATABASE_URL")
|
|
||||||
if not db_url:
|
|
||||||
print("ERROR: DATABASE_URL environment variable not set")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Export DATABASE_URL for alembic
|
|
||||||
os.environ["DATABASE_URL"] = db_url
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Use the robust migration system from migration.py
|
|
||||||
# This handles all edge cases and recovery scenarios automatically
|
|
||||||
print("Starting database migrations...")
|
|
||||||
|
|
||||||
# Import and run the migration function
|
|
||||||
from backend.migration import run_migrations
|
|
||||||
run_migrations()
|
|
||||||
|
|
||||||
print("Database migrations completed successfully!")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"ERROR: Failed to run migrations: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
run_migrations()
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Moderation Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
|
||||||
COPY backend/security/profanity.py /app/backend/security/profanity.py
|
|
||||||
COPY backend/similarity.py /app/backend/similarity.py
|
|
||||||
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=moderation
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.moderation import router as moderation_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Moderation Service")
|
|
||||||
app.include_router(moderation_router, prefix="/moderation")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Profile Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
|
||||||
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=profile
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.profile import router as profile_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Profile Service")
|
|
||||||
app.include_router(profile_router, prefix="/profile")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# Push Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/push.py /app/backend/routes/push.py
|
|
||||||
COPY backend/push_service.py /app/backend/push_service.py
|
|
||||||
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=push
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.push import router as push_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="Push Service")
|
|
||||||
app.include_router(push_router, prefix="/push")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# WebRTC Service Dockerfile
|
|
||||||
FROM backend/base:latest
|
|
||||||
|
|
||||||
# Copy service-specific files
|
|
||||||
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
|
||||||
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
|
||||||
|
|
||||||
# Set service name for entrypoint
|
|
||||||
ENV SERVICE_NAME=webrtc
|
|
||||||
|
|
||||||
EXPOSE 8301
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
from fastapi import FastAPI
|
|
||||||
from backend.routes.webrtc import router as webrtc_router
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = FastAPI(title="WebRTC Service")
|
|
||||||
app.include_router(webrtc_router, prefix="/webrtc")
|
|
||||||
|
|
||||||
import os, uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 8301)))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Shared modules package
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/database.db")
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET", "default-jwt-secret-for-development")
|
|
||||||
JWT_ALGORITHM = "HS256"
|
|
||||||
# Token inactivity expiration - token expires if not used for this duration
|
|
||||||
TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity
|
|
||||||
# Maximum token lifetime (safety net) - tokens expire after this regardless of usage
|
|
||||||
MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum
|
|
||||||
|
|
||||||
# Owner user
|
|
||||||
OWNER_USERNAME = os.getenv("OWNER_USERNAME", "owner")
|
|
||||||
|
|
||||||
# Push notifications
|
|
||||||
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY", "")
|
|
||||||
VAPID_PUBLIC_KEY = os.getenv("VAPID_PUBLIC_KEY", "")
|
|
||||||
VAPID_SUBJECT = os.getenv("VAPID_SUBJECT", "mailto:admin@example.com")
|
|
||||||
|
|
||||||
# Rate limiting
|
|
||||||
RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
|
||||||
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
|
||||||
|
|
||||||
# File uploads
|
|
||||||
MAX_UPLOAD_SIZE = int(os.getenv("MAX_UPLOAD_SIZE", "10485760")) # 10MB
|
|
||||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp4", ".mov", ".avi", ".mp3", ".wav"}
|
|
||||||
|
|
||||||
# WebSocket
|
|
||||||
WEBSOCKET_PING_INTERVAL = 30
|
|
||||||
WEBSOCKET_PING_TIMEOUT = 60
|
|
||||||
|
|
||||||
# Encryption
|
|
||||||
ENCRYPTION_KEY_LENGTH = 32
|
|
||||||
ENCRYPTION_NONCE_LENGTH = 12
|
|
||||||
|
|
||||||
# Moderation
|
|
||||||
PROFANITY_THRESHOLD = float(os.getenv("PROFANITY_THRESHOLD", "0.8"))
|
|
||||||
SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", "0.85"))
|
|
||||||
|
|
||||||
# WebRTC
|
|
||||||
WEBRTC_ICE_SERVERS = [
|
|
||||||
{"urls": "stun:stun.l.google.com:19302"},
|
|
||||||
{"urls": "stun:stun1.l.google.com:19302"}
|
|
||||||
]
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
|
||||||
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import os
|
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from .constants import DATABASE_URL
|
|
||||||
|
|
||||||
# Database connection settings
|
|
||||||
POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20"))
|
|
||||||
MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40"))
|
|
||||||
POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800"))
|
|
||||||
POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
|
||||||
|
|
||||||
POOL_CONFIG = {
|
|
||||||
"pool_size": POOL_SIZE,
|
|
||||||
"max_overflow": MAX_OVERFLOW,
|
|
||||||
"pool_recycle": POOL_RECYCLE,
|
|
||||||
"pool_timeout": POOL_TIMEOUT,
|
|
||||||
"pool_pre_ping": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def create_engine_from_url(database_url: str):
|
|
||||||
"""Create SQLAlchemy engine from database URL."""
|
|
||||||
connect_args = {}
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
|
|
||||||
engine_kwargs = {
|
|
||||||
"pool_size": POOL_SIZE,
|
|
||||||
"max_overflow": MAX_OVERFLOW,
|
|
||||||
"pool_recycle": POOL_RECYCLE,
|
|
||||||
"pool_pre_ping": True,
|
|
||||||
"pool_timeout": POOL_TIMEOUT,
|
|
||||||
}
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
database_url,
|
|
||||||
connect_args=connect_args,
|
|
||||||
**engine_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
return engine
|
|
||||||
|
|
||||||
# Create engine - this should be called by each service with its own DATABASE_URL
|
|
||||||
def get_engine(database_url: str = None):
|
|
||||||
"""Get SQLAlchemy engine for the given database URL."""
|
|
||||||
url = database_url or DATABASE_URL
|
|
||||||
return create_engine_from_url(url)
|
|
||||||
|
|
||||||
# Session factory - create per service
|
|
||||||
def get_session_factory(database_url: str = None):
|
|
||||||
"""Get session factory for the given database URL."""
|
|
||||||
engine = get_engine(database_url)
|
|
||||||
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
# Dependency for FastAPI - create per service
|
|
||||||
def get_db(database_url: str = None):
|
|
||||||
"""FastAPI dependency to get database session."""
|
|
||||||
SessionLocal = get_session_factory(database_url)
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from backend.shared.utils import verify_token
|
|
||||||
from backend.shared.models import User, DeviceSession
|
|
||||||
from backend.shared.db import get_session_factory
|
|
||||||
import logging
|
|
||||||
|
|
||||||
security = HTTPBearer()
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
# Зависимость для получения сессии БД
|
|
||||||
SessionLocal = get_session_factory()
|
|
||||||
|
|
||||||
def get_db():
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
# Зависимость для получения текущего пользователя
|
|
||||||
def get_current_user(
|
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> User:
|
|
||||||
token = credentials.credentials
|
|
||||||
try:
|
|
||||||
payload = verify_token(token)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("get_current_user: token verification error: %s", str(e))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
if not payload:
|
|
||||||
logger.info("get_current_user: verify_token returned empty payload")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid or expired token",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
user = db.query(User).filter(User.id == payload["user_id"]).first()
|
|
||||||
if not user:
|
|
||||||
logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id"))
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="User not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
if user.id == 1 and user.suspended:
|
|
||||||
user.suspended = False
|
|
||||||
user.suspension_reason = None
|
|
||||||
db.commit()
|
|
||||||
db.refresh(user)
|
|
||||||
|
|
||||||
# Validate device session from JWT
|
|
||||||
session_id = payload.get("session_id")
|
|
||||||
if not session_id:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Invalid session",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
device_session = (
|
|
||||||
db.query(DeviceSession)
|
|
||||||
.filter(DeviceSession.user_id == user.id, DeviceSession.session_id == session_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not device_session or device_session.revoked:
|
|
||||||
logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session revoked or not found",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if session has been inactive for too long (sliding expiration)
|
|
||||||
from backend.shared.constants import TOKEN_INACTIVITY_EXPIRE_HOURS
|
|
||||||
inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS)
|
|
||||||
if device_session.last_seen < inactivity_threshold:
|
|
||||||
# Session expired due to inactivity - revoke it
|
|
||||||
device_session.revoked = True
|
|
||||||
db.commit()
|
|
||||||
logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Session expired due to inactivity",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Touch last_seen on valid session (sliding expiration - extends token life)
|
|
||||||
device_session.last_seen = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Check if user is suspended
|
|
||||||
if user.suspended:
|
|
||||||
logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account suspended",
|
|
||||||
headers={"suspension_reason": user.suspension_reason or "No reason provided"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if user is deleted
|
|
||||||
if user.deleted:
|
|
||||||
logger.info("get_current_user: account deleted for user_id=%s", user.id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Account deleted",
|
|
||||||
)
|
|
||||||
|
|
||||||
request.state.current_user = user
|
|
||||||
request.state.session_id = session_id
|
|
||||||
|
|
||||||
return user
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, BigInteger, UniqueConstraint
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
__table_args__ = {"schema": "account_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
|
||||||
hashed_password = Column(String(255), nullable=False)
|
|
||||||
salt = Column(String(64), nullable=False)
|
|
||||||
display_name = Column(String(100), nullable=True)
|
|
||||||
bio = Column(Text, nullable=True)
|
|
||||||
avatar_url = Column(String(255), nullable=True)
|
|
||||||
is_online = Column(Boolean, default=False)
|
|
||||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
verified = Column(Boolean, default=False)
|
|
||||||
verification_token = Column(String(255), nullable=True)
|
|
||||||
reset_token = Column(String(255), nullable=True)
|
|
||||||
reset_token_expires = Column(DateTime, nullable=True)
|
|
||||||
two_factor_enabled = Column(Boolean, default=False)
|
|
||||||
two_factor_secret = Column(String(255), nullable=True)
|
|
||||||
login_attempts = Column(Integer, default=0)
|
|
||||||
locked_until = Column(DateTime, nullable=True)
|
|
||||||
public_key = Column(Text, nullable=True)
|
|
||||||
private_key = Column(Text, nullable=True)
|
|
||||||
encryption_enabled = Column(Boolean, default=False)
|
|
||||||
suspended = Column(Boolean, default=False)
|
|
||||||
suspension_reason = Column(Text, nullable=True)
|
|
||||||
deleted = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
messages = relationship("Message", back_populates="sender", cascade="all, delete-orphan")
|
|
||||||
message_recipients = relationship("MessageRecipient", back_populates="recipient", cascade="all, delete-orphan")
|
|
||||||
devices = relationship("Device", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
class Message(Base):
|
|
||||||
__tablename__ = "messages"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
content = Column(Text, nullable=False)
|
|
||||||
content_type = Column(String(50), default="text")
|
|
||||||
encrypted_content = Column(Text, nullable=True)
|
|
||||||
signature = Column(Text, nullable=True)
|
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow, index=True)
|
|
||||||
edited_at = Column(DateTime, nullable=True)
|
|
||||||
edited = Column(Boolean, default=False)
|
|
||||||
deleted = Column(Boolean, default=False)
|
|
||||||
reply_to_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
|
||||||
thread_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
|
||||||
is_public = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
sender = relationship("User", back_populates="messages")
|
|
||||||
recipients = relationship("MessageRecipient", back_populates="message", cascade="all, delete-orphan")
|
|
||||||
reply_to = relationship("Message", remote_side=[id], foreign_keys=[reply_to_id])
|
|
||||||
thread = relationship("Message", remote_side=[id], foreign_keys=[thread_id])
|
|
||||||
reactions = relationship("MessageReaction", back_populates="message", cascade="all, delete-orphan")
|
|
||||||
files = relationship("MessageFile", back_populates="message", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
class MessageRecipient(Base):
|
|
||||||
__tablename__ = "message_recipients"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
|
||||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
read_at = Column(DateTime, nullable=True)
|
|
||||||
delivered_at = Column(DateTime, nullable=True)
|
|
||||||
encrypted_key = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
message = relationship("Message", back_populates="recipients")
|
|
||||||
recipient = relationship("User", back_populates="message_recipients")
|
|
||||||
|
|
||||||
class MessageReaction(Base):
|
|
||||||
__tablename__ = "message_reactions"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
reaction = Column(String(50), nullable=False)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
message = relationship("Message", back_populates="reactions")
|
|
||||||
|
|
||||||
class Device(Base):
|
|
||||||
__tablename__ = "devices"
|
|
||||||
__table_args__ = {"schema": "device_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
device_id = Column(String(255), unique=True, nullable=False, index=True)
|
|
||||||
device_name = Column(String(255), nullable=True)
|
|
||||||
device_type = Column(String(50), nullable=True)
|
|
||||||
public_key = Column(Text, nullable=True)
|
|
||||||
signed_prekey = Column(Text, nullable=True)
|
|
||||||
one_time_prekeys = Column(JSON, nullable=True)
|
|
||||||
last_active = Column(DateTime, default=datetime.utcnow)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User", back_populates="devices")
|
|
||||||
push_subscriptions = relationship("PushSubscription", back_populates="device", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
class PushSubscription(Base):
|
|
||||||
__tablename__ = "push_subscriptions"
|
|
||||||
__table_args__ = {"schema": "push_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
device_id = Column(BigInteger, ForeignKey("device_schema.devices.id"), nullable=True, index=True)
|
|
||||||
endpoint = Column(String(500), nullable=False)
|
|
||||||
p256dh = Column(String(255), nullable=False)
|
|
||||||
auth = Column(String(255), nullable=False)
|
|
||||||
user_agent = Column(String(500), nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User", back_populates="push_subscriptions")
|
|
||||||
device = relationship("Device", back_populates="push_subscriptions")
|
|
||||||
|
|
||||||
class WebRTCSession(Base):
|
|
||||||
__tablename__ = "webrtc_sessions"
|
|
||||||
__table_args__ = {"schema": "webrtc_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
session_id = Column(String(255), unique=True, nullable=False, index=True)
|
|
||||||
initiator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
participant_ids = Column(JSON, nullable=False)
|
|
||||||
offer = Column(JSON, nullable=True)
|
|
||||||
answer = Column(JSON, nullable=True)
|
|
||||||
ice_candidates = Column(JSON, nullable=True)
|
|
||||||
status = Column(String(50), default="pending")
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
|
|
||||||
class ModerationAction(Base):
|
|
||||||
__tablename__ = "moderation_actions"
|
|
||||||
__table_args__ = {"schema": "moderation_schema"}
|
|
||||||
|
|
||||||
id = Column(BigInteger, primary_key=True, index=True)
|
|
||||||
moderator_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
target_user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=True)
|
|
||||||
target_message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=True)
|
|
||||||
action_type = Column(String(50), nullable=False)
|
|
||||||
reason = Column(Text, nullable=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
expires_at = Column(DateTime, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MessageFile(Base):
|
|
||||||
__tablename__ = "message_file"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
|
||||||
path = Column(Text, nullable=False)
|
|
||||||
name = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
message = relationship("Message", back_populates="files")
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoPublicKey(Base):
|
|
||||||
__tablename__ = "crypto_public_key"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
|
||||||
public_key_b64 = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class CryptoBackup(Base):
|
|
||||||
__tablename__ = "crypto_backup"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, unique=True)
|
|
||||||
blob_json = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
|
|
||||||
class DMEnvelope(Base):
|
|
||||||
__tablename__ = "dm_envelope"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
iv_b64 = Column(Text, nullable=False)
|
|
||||||
ciphertext_b64 = Column(Text, nullable=False)
|
|
||||||
salt_b64 = Column(Text, nullable=False)
|
|
||||||
iv2_b64 = Column(Text, nullable=False)
|
|
||||||
wrapped_mk_b64 = Column(Text, nullable=False)
|
|
||||||
reply_to_id = Column(Integer, nullable=True)
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
files = relationship("DMFile", back_populates="message", cascade="all, delete-orphan", lazy="select")
|
|
||||||
reactions = relationship("DMReaction", cascade="all, delete-orphan", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
class DMFile(Base):
|
|
||||||
__tablename__ = "dm_file"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
|
||||||
sender_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
recipient_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
name = Column(Text, nullable=False)
|
|
||||||
path = Column(Text, nullable=False)
|
|
||||||
|
|
||||||
message = relationship("DMEnvelope", back_populates="files")
|
|
||||||
|
|
||||||
|
|
||||||
class FcmToken(Base):
|
|
||||||
__tablename__ = "fcm_token"
|
|
||||||
__table_args__ = {"schema": "push_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
token = Column(Text, nullable=False, unique=True)
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
|
||||||
|
|
||||||
|
|
||||||
class Reaction(Base):
|
|
||||||
__tablename__ = "reaction"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
message_id = Column(BigInteger, ForeignKey("messaging_schema.messages.id"), nullable=False, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User")
|
|
||||||
|
|
||||||
# Ensure unique combination of message, user, and emoji
|
|
||||||
__table_args__ = (UniqueConstraint('message_id', 'user_id', 'emoji', name='unique_reaction'),)
|
|
||||||
|
|
||||||
|
|
||||||
class DMReaction(Base):
|
|
||||||
__tablename__ = "dm_reaction"
|
|
||||||
__table_args__ = {"schema": "messaging_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
dm_envelope_id = Column(Integer, ForeignKey("messaging_schema.dm_envelope.id"), nullable=False, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False)
|
|
||||||
emoji = Column(String(10), nullable=False) # Store emoji as string
|
|
||||||
timestamp = Column(DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
user = relationship("User")
|
|
||||||
dm_envelope = relationship("DMEnvelope", overlaps="reactions")
|
|
||||||
|
|
||||||
# Ensure unique combination of dm_envelope, user, and emoji
|
|
||||||
__table_args__ = (UniqueConstraint('dm_envelope_id', 'user_id', 'emoji', name='unique_dm_reaction'),)
|
|
||||||
|
|
||||||
|
|
||||||
# Tracks authenticated device sessions per user
|
|
||||||
class DeviceSession(Base):
|
|
||||||
__tablename__ = "device_session"
|
|
||||||
__table_args__ = {"schema": "device_schema"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
|
|
||||||
# Raw User-Agent for reference/debugging
|
|
||||||
raw_user_agent = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# Parsed fields
|
|
||||||
device_name = Column(String(128), nullable=True)
|
|
||||||
device_type = Column(String(32), nullable=True) # desktop/mobile/tablet/bot/unknown
|
|
||||||
os_name = Column(String(64), nullable=True)
|
|
||||||
os_version = Column(String(64), nullable=True)
|
|
||||||
browser_name = Column(String(64), nullable=True)
|
|
||||||
browser_version = Column(String(64), nullable=True)
|
|
||||||
brand = Column(String(64), nullable=True)
|
|
||||||
model = Column(String(64), nullable=True)
|
|
||||||
|
|
||||||
# Session identity embedded into JWTs
|
|
||||||
session_id = Column(String(64), unique=True, nullable=False, index=True)
|
|
||||||
|
|
||||||
# Lifecycle
|
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
|
||||||
last_seen = Column(DateTime, default=datetime.now)
|
|
||||||
revoked = Column(Boolean, default=False)
|
|
||||||
|
|
||||||
# Relationship back to user (optional lazy to avoid heavy loads)
|
|
||||||
user = relationship("User", lazy="select")
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic models
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
display_name: str
|
|
||||||
password: str
|
|
||||||
confirm_password: str
|
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
|
||||||
currentPasswordDerived: str
|
|
||||||
newPasswordDerived: str
|
|
||||||
logoutAllExceptCurrent: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
reply_to_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class EditMessageRequest(BaseModel):
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteMessageRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateBioRequest(BaseModel):
|
|
||||||
bio: str
|
|
||||||
|
|
||||||
|
|
||||||
class PushSubscriptionRequest(BaseModel):
|
|
||||||
endpoint: str
|
|
||||||
keys: dict
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
display_name: str
|
|
||||||
profile_picture: str | None
|
|
||||||
bio: str | None
|
|
||||||
online: bool
|
|
||||||
last_seen: datetime | None
|
|
||||||
created_at: datetime | None
|
|
||||||
verified: bool
|
|
||||||
suspended: bool
|
|
||||||
suspension_reason: str | None
|
|
||||||
deleted: bool
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class MessageResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
content: str
|
|
||||||
timestamp: datetime
|
|
||||||
is_author: bool
|
|
||||||
is_read: bool
|
|
||||||
username: str
|
|
||||||
profile_picture: str | None
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class ReactionRequest(BaseModel):
|
|
||||||
message_id: int
|
|
||||||
emoji: str
|
|
||||||
|
|
||||||
|
|
||||||
class ReactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
message_id: int
|
|
||||||
user_id: int
|
|
||||||
emoji: str
|
|
||||||
timestamp: datetime
|
|
||||||
username: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class DMReactionRequest(BaseModel):
|
|
||||||
dm_envelope_id: int
|
|
||||||
emoji: str
|
|
||||||
|
|
||||||
|
|
||||||
class DMReactionResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
dm_envelope_id: int
|
|
||||||
user_id: int
|
|
||||||
emoji: str
|
|
||||||
timestamp: datetime
|
|
||||||
username: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateLog(Base):
|
|
||||||
"""Stores update sequence numbers and updates for gap detection"""
|
|
||||||
__tablename__ = "update_log"
|
|
||||||
__table_args__ = {"schema": "public"}
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
user_id = Column(BigInteger, ForeignKey("account_schema.users.id"), nullable=False, index=True)
|
|
||||||
sequence = Column(Integer, nullable=False, index=True)
|
|
||||||
updates = Column(Text, nullable=False) # JSON array of updates
|
|
||||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_id", "sequence", name="uq_user_sequence"),
|
|
||||||
)
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import secrets
|
|
||||||
import string
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import Optional
|
|
||||||
import re
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
from cryptography.hazmat.backends import default_backend
|
|
||||||
import nacl.secret
|
|
||||||
import nacl.utils
|
|
||||||
from fastapi import Request
|
|
||||||
import jwt
|
|
||||||
import bcrypt
|
|
||||||
from backend.shared.constants import JWT_SECRET_KEY, JWT_ALGORITHM, MAX_TOKEN_LIFETIME_HOURS
|
|
||||||
import ipaddress
|
|
||||||
|
|
||||||
def generate_secure_token(length: int = 32) -> str:
|
|
||||||
"""Generate a cryptographically secure random token."""
|
|
||||||
alphabet = string.ascii_letters + string.digits
|
|
||||||
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
||||||
|
|
||||||
def hash_password(password: str, salt: Optional[bytes] = None) -> tuple[str, bytes]:
|
|
||||||
"""Hash a password with PBKDF2 and return (hash, salt)."""
|
|
||||||
if salt is None:
|
|
||||||
salt = secrets.token_bytes(32)
|
|
||||||
|
|
||||||
kdf = PBKDF2HMAC(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=32,
|
|
||||||
salt=salt,
|
|
||||||
iterations=100000,
|
|
||||||
backend=default_backend()
|
|
||||||
)
|
|
||||||
|
|
||||||
key = kdf.derive(password.encode())
|
|
||||||
return base64.b64encode(key).decode(), salt
|
|
||||||
|
|
||||||
def verify_password(password: str, hashed: str, salt: bytes) -> bool:
|
|
||||||
"""Verify a password against its hash and salt."""
|
|
||||||
try:
|
|
||||||
key = base64.b64decode(hashed)
|
|
||||||
kdf = PBKDF2HMAC(
|
|
||||||
algorithm=hashes.SHA256(),
|
|
||||||
length=32,
|
|
||||||
salt=salt,
|
|
||||||
iterations=100000,
|
|
||||||
backend=default_backend()
|
|
||||||
)
|
|
||||||
kdf.verify(password.encode(), key)
|
|
||||||
return True
|
|
||||||
except:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def generate_verification_token() -> str:
|
|
||||||
"""Generate a verification token for email verification."""
|
|
||||||
return generate_secure_token(64)
|
|
||||||
|
|
||||||
def generate_reset_token() -> str:
|
|
||||||
"""Generate a password reset token."""
|
|
||||||
return generate_secure_token(64)
|
|
||||||
|
|
||||||
def get_client_ip(request: Request) -> str:
|
|
||||||
"""Extract the real client IP from the request."""
|
|
||||||
# Check X-Forwarded-For header first
|
|
||||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
||||||
if forwarded_for:
|
|
||||||
# Take the first IP in case of multiple proxies
|
|
||||||
client_ip = forwarded_for.split(",")[0].strip()
|
|
||||||
try:
|
|
||||||
# Validate IP address
|
|
||||||
ipaddress.ip_address(client_ip)
|
|
||||||
return client_ip
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check X-Real-IP header
|
|
||||||
real_ip = request.headers.get("X-Real-IP")
|
|
||||||
if real_ip:
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(real_ip)
|
|
||||||
return real_ip
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fallback to request.client.host
|
|
||||||
client_host = request.client.host if request.client else "unknown"
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(client_host)
|
|
||||||
return client_host
|
|
||||||
except ValueError:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
def validate_email(email: str) -> bool:
|
|
||||||
"""Validate email address format."""
|
|
||||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
||||||
return re.match(pattern, email) is not None
|
|
||||||
|
|
||||||
def validate_username(username: str) -> bool:
|
|
||||||
"""Validate username format."""
|
|
||||||
if not username or len(username) < 3 or len(username) > 50:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Allow alphanumeric, underscore, and hyphen
|
|
||||||
pattern = r'^[a-zA-Z0-9_-]+$'
|
|
||||||
return re.match(pattern, username) is not None
|
|
||||||
|
|
||||||
def sanitize_filename(filename: str) -> str:
|
|
||||||
"""Sanitize filename to prevent directory traversal."""
|
|
||||||
return re.sub(r'[^\w\.-]', '_', filename)
|
|
||||||
|
|
||||||
def generate_file_hash(content: bytes) -> str:
|
|
||||||
"""Generate SHA256 hash of file content."""
|
|
||||||
return hashlib.sha256(content).hexdigest()
|
|
||||||
|
|
||||||
def encrypt_data(data: str, key: bytes) -> str:
|
|
||||||
"""Encrypt data using NaCl secret box."""
|
|
||||||
box = nacl.secret.SecretBox(key)
|
|
||||||
encrypted = box.encrypt(data.encode())
|
|
||||||
return base64.b64encode(encrypted).decode()
|
|
||||||
|
|
||||||
def decrypt_data(encrypted_data: str, key: bytes) -> str:
|
|
||||||
"""Decrypt data using NaCl secret box."""
|
|
||||||
box = nacl.secret.SecretBox(key)
|
|
||||||
encrypted = base64.b64decode(encrypted_data)
|
|
||||||
decrypted = box.decrypt(encrypted)
|
|
||||||
return decrypted.decode()
|
|
||||||
|
|
||||||
def generate_encryption_key() -> bytes:
|
|
||||||
"""Generate a new encryption key."""
|
|
||||||
return nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE)
|
|
||||||
|
|
||||||
def format_datetime(dt: datetime) -> str:
|
|
||||||
"""Format datetime for API responses."""
|
|
||||||
return dt.isoformat()
|
|
||||||
|
|
||||||
def parse_datetime(dt_str: str) -> Optional[datetime]:
|
|
||||||
"""Parse datetime from API requests."""
|
|
||||||
try:
|
|
||||||
return datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def calculate_age(birth_date: datetime) -> int:
|
|
||||||
"""Calculate age from birth date."""
|
|
||||||
today = datetime.now()
|
|
||||||
age = today.year - birth_date.year
|
|
||||||
if today.month < birth_date.month or (today.month == birth_date.month and today.day < birth_date.day):
|
|
||||||
age -= 1
|
|
||||||
return age
|
|
||||||
|
|
||||||
def truncate_text(text: str, max_length: int, suffix: str = "...") -> str:
|
|
||||||
"""Truncate text to max length with suffix."""
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
return text[:max_length - len(suffix)] + suffix
|
|
||||||
|
|
||||||
def is_valid_url(url: str) -> bool:
|
|
||||||
"""Validate URL format."""
|
|
||||||
pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
|
||||||
return re.match(pattern, url) is not None
|
|
||||||
|
|
||||||
def generate_device_id() -> str:
|
|
||||||
"""Generate a unique device identifier."""
|
|
||||||
return generate_secure_token(32)
|
|
||||||
|
|
||||||
def normalize_phone_number(phone: str) -> str:
|
|
||||||
"""Normalize phone number format."""
|
|
||||||
# Remove all non-digit characters except +
|
|
||||||
normalized = re.sub(r'[^\d+]', '', phone)
|
|
||||||
|
|
||||||
# Ensure it starts with +
|
|
||||||
if not normalized.startswith('+'):
|
|
||||||
if normalized.startswith('00'):
|
|
||||||
normalized = '+' + normalized[2:]
|
|
||||||
else:
|
|
||||||
normalized = '+' + normalized
|
|
||||||
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def create_token(user_id: int, username: str, session_id: str) -> str:
|
|
||||||
# Set a long expiration as safety net (actual expiration based on inactivity)
|
|
||||||
expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS)
|
|
||||||
payload = {
|
|
||||||
"user_id": user_id,
|
|
||||||
"username": username,
|
|
||||||
"session_id": session_id,
|
|
||||||
"exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int)
|
|
||||||
}
|
|
||||||
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[dict]:
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
|
|
||||||
return payload
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_admin(user) -> bool:
|
|
||||||
return user.id == 1
|
|
||||||
|
|
||||||
|
|
||||||
def convert_user(user) -> dict:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"created_at": user.created_at.isoformat(),
|
|
||||||
"last_seen": user.last_seen.isoformat(),
|
|
||||||
"online": user.online,
|
|
||||||
"username": user.username,
|
|
||||||
"display_name": user.display_name,
|
|
||||||
"profile_picture": user.profile_picture,
|
|
||||||
"bio": user.bio,
|
|
||||||
"admin": _is_admin(user),
|
|
||||||
"verified": user.verified,
|
|
||||||
"suspended": user.suspended or False,
|
|
||||||
"suspension_reason": user.suspension_reason,
|
|
||||||
"deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
from pydantic import BaseModel, EmailStr, Field, validator
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_username(username: str) -> bool:
|
|
||||||
if len(username) < 3 or len(username) > 20:
|
|
||||||
return False
|
|
||||||
# Only allow English letters, numbers, dashes and underscores
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_display_name(display_name: str) -> bool:
|
|
||||||
if len(display_name) < 1 or len(display_name) > 64:
|
|
||||||
return False
|
|
||||||
# Check if not blank (only whitespace)
|
|
||||||
if not display_name.strip():
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def is_valid_password(password: str) -> bool:
|
|
||||||
if len(password) < 5 or len(password) > 50:
|
|
||||||
return False
|
|
||||||
if re.search(r'[\s\u180E\u200B-\u200D\u2060\uFEFF]', password):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
|
||||||
username: str = Field(min_length=3, max_length=50)
|
|
||||||
email: EmailStr
|
|
||||||
password: str = Field(min_length=8, max_length=128)
|
|
||||||
display_name: Optional[str] = Field(None, max_length=100)
|
|
||||||
|
|
||||||
@validator('username')
|
|
||||||
def username_alphanumeric(cls, v):
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
|
|
||||||
raise ValueError('Username must be alphanumeric with underscores or hyphens')
|
|
||||||
return v
|
|
||||||
|
|
||||||
@validator('password')
|
|
||||||
def password_strength(cls, v):
|
|
||||||
if not re.search(r'[A-Z]', v):
|
|
||||||
raise ValueError('Password must contain at least one uppercase letter')
|
|
||||||
if not re.search(r'[a-z]', v):
|
|
||||||
raise ValueError('Password must contain at least one lowercase letter')
|
|
||||||
if not re.search(r'\d', v):
|
|
||||||
raise ValueError('Password must contain at least one digit')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class UserLogin(BaseModel):
|
|
||||||
username_or_email: str = Field(min_length=1, max_length=100)
|
|
||||||
password: str = Field(min_length=1, max_length=128)
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
display_name: Optional[str] = Field(None, max_length=100)
|
|
||||||
bio: Optional[str] = Field(None, max_length=500)
|
|
||||||
avatar_url: Optional[str] = Field(None, max_length=255)
|
|
||||||
|
|
||||||
@validator('avatar_url')
|
|
||||||
def validate_avatar_url(cls, v):
|
|
||||||
if v and not v.startswith(('http://', 'https://')):
|
|
||||||
raise ValueError('Avatar URL must be a valid HTTP/HTTPS URL')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class MessageCreate(BaseModel):
|
|
||||||
content: str = Field(min_length=1, max_length=10000)
|
|
||||||
content_type: str = Field(default="text", pattern=r'^(text|image|video|audio|file)$')
|
|
||||||
reply_to_id: Optional[int] = None
|
|
||||||
recipient_ids: list[int] = Field(min_items=1, max_items=100)
|
|
||||||
|
|
||||||
class MessageUpdate(BaseModel):
|
|
||||||
content: str = Field(min_length=1, max_length=10000)
|
|
||||||
|
|
||||||
class DeviceRegister(BaseModel):
|
|
||||||
device_id: str = Field(min_length=1, max_length=255)
|
|
||||||
device_name: Optional[str] = Field(None, max_length=255)
|
|
||||||
device_type: Optional[str] = Field(None, max_length=50)
|
|
||||||
public_key: Optional[str] = Field(None, max_length=10000)
|
|
||||||
|
|
||||||
class PushSubscriptionCreate(BaseModel):
|
|
||||||
endpoint: str = Field(max_length=500)
|
|
||||||
p256dh: str = Field(max_length=255)
|
|
||||||
auth: str = Field(max_length=255)
|
|
||||||
device_id: Optional[str] = Field(None, max_length=255)
|
|
||||||
|
|
||||||
class WebRTCOffer(BaseModel):
|
|
||||||
offer: dict
|
|
||||||
participant_ids: list[int] = Field(min_items=1, max_items=10)
|
|
||||||
|
|
||||||
class WebRTCAnswer(BaseModel):
|
|
||||||
answer: dict
|
|
||||||
session_id: str = Field(max_length=255)
|
|
||||||
|
|
||||||
class WebRTCIceCandidate(BaseModel):
|
|
||||||
candidate: dict
|
|
||||||
session_id: str = Field(max_length=255)
|
|
||||||
|
|
||||||
class ModerationActionCreate(BaseModel):
|
|
||||||
target_user_id: Optional[int] = None
|
|
||||||
target_message_id: Optional[int] = None
|
|
||||||
action_type: str = Field(pattern=r'^(ban|mute|delete|warn)$')
|
|
||||||
reason: Optional[str] = Field(None, max_length=1000)
|
|
||||||
duration_hours: Optional[int] = Field(None, gt=0, le=8760) # Max 1 year
|
|
||||||
|
|
||||||
class PasswordResetRequest(BaseModel):
|
|
||||||
email: EmailStr
|
|
||||||
|
|
||||||
class PasswordReset(BaseModel):
|
|
||||||
token: str = Field(min_length=64, max_length=64)
|
|
||||||
new_password: str = Field(..., min_length=8, max_length=128)
|
|
||||||
|
|
||||||
@validator('new_password')
|
|
||||||
def password_strength(cls, v):
|
|
||||||
if not re.search(r'[A-Z]', v):
|
|
||||||
raise ValueError('Password must contain at least one uppercase letter')
|
|
||||||
if not re.search(r'[a-z]', v):
|
|
||||||
raise ValueError('Password must contain at least one lowercase letter')
|
|
||||||
if not re.search(r'\d', v):
|
|
||||||
raise ValueError('Password must contain at least one digit')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class TwoFactorSetup(BaseModel):
|
|
||||||
code: str = Field(pattern=r'^\d{6}$')
|
|
||||||
|
|
||||||
class TwoFactorVerify(BaseModel):
|
|
||||||
code: str = Field(pattern=r'^\d{6}$')
|
|
||||||
|
|
||||||
class EmailVerification(BaseModel):
|
|
||||||
token: str = Field(min_length=64, max_length=64)
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .registry import WebSocketHandlerRegistry
|
from websocket.registry import WebSocketHandlerRegistry
|
||||||
|
|
||||||
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency
|
||||||
# Import them directly from websocket.handlers when needed
|
# Import them directly from websocket.handlers when needed
|
||||||
@@ -6,8 +6,8 @@ from typing import Any
|
|||||||
from fastapi import HTTPException, WebSocket, Request
|
from fastapi import HTTPException, WebSocket, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.services.messaging.files.websocket.registry import WebSocketHandlerRegistry
|
from websocket.registry import WebSocketHandlerRegistry
|
||||||
from backend.routes.messaging import (
|
from routes.messaging import (
|
||||||
MessaggingSocketManager,
|
MessaggingSocketManager,
|
||||||
_send_message_internal,
|
_send_message_internal,
|
||||||
_edit_message_internal,
|
_edit_message_internal,
|
||||||
@@ -17,7 +17,7 @@ from backend.routes.messaging import (
|
|||||||
add_reaction,
|
add_reaction,
|
||||||
add_dm_reaction,
|
add_dm_reaction,
|
||||||
)
|
)
|
||||||
from backend.shared.models import (
|
from models import (
|
||||||
User,
|
User,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
EditMessageRequest,
|
EditMessageRequest,
|
||||||
@@ -26,7 +26,7 @@ from backend.shared.models import (
|
|||||||
DMReactionRequest,
|
DMReactionRequest,
|
||||||
UpdateLog,
|
UpdateLog,
|
||||||
)
|
)
|
||||||
from backend.security.audit import log_access, log_dm
|
from security.audit import log_access, log_dm
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
@@ -2,8 +2,8 @@ from fastapi import HTTPException
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from backend.shared.dependencies import get_current_user
|
from dependencies import get_current_user
|
||||||
from backend.shared.models import User
|
from models import User
|
||||||
|
|
||||||
|
|
||||||
def extract_token_from_data(data: dict) -> str | None:
|
def extract_token_from_data(data: dict) -> str | None:
|
||||||
@@ -79,6 +79,32 @@ beta.fromchat.ru {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
api.getgadgets.toolbox-io.ru {
|
||||||
reverse_proxy 95.165.0.162:8400
|
reverse_proxy 95.165.0.162:8400
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
-- Initialize database roles and schemas for FromChat microservices
|
|
||||||
-- This script creates dedicated users with limited privileges for each service
|
|
||||||
|
|
||||||
-- Create service-specific database roles with limited privileges
|
|
||||||
-- All services use the same password
|
|
||||||
CREATE ROLE account_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE profile_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE device_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE messaging_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE push_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE webrtc_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE moderation_service_user LOGIN PASSWORD 'development';
|
|
||||||
CREATE ROLE gateway_user LOGIN PASSWORD 'development';
|
|
||||||
|
|
||||||
-- Create dedicated schemas for each service
|
|
||||||
CREATE SCHEMA IF NOT EXISTS account_schema AUTHORIZATION account_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS profile_schema AUTHORIZATION profile_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS device_schema AUTHORIZATION device_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS messaging_schema AUTHORIZATION messaging_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS push_schema AUTHORIZATION push_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS webrtc_schema AUTHORIZATION webrtc_service_user;
|
|
||||||
CREATE SCHEMA IF NOT EXISTS moderation_schema AUTHORIZATION moderation_service_user;
|
|
||||||
|
|
||||||
-- Grant basic connection privileges
|
|
||||||
GRANT CONNECT ON DATABASE fromchat TO account_service_user, profile_service_user, device_service_user, messaging_service_user, push_service_user, webrtc_service_user, moderation_service_user;
|
|
||||||
|
|
||||||
-- Grant schema-level privileges (limited to each service's schema)
|
|
||||||
-- Account service
|
|
||||||
GRANT USAGE ON SCHEMA account_schema TO account_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA account_schema TO account_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA account_schema TO account_service_user;
|
|
||||||
|
|
||||||
-- Profile service
|
|
||||||
GRANT USAGE ON SCHEMA profile_schema TO profile_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA profile_schema TO profile_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA profile_schema TO profile_service_user;
|
|
||||||
|
|
||||||
-- Device service
|
|
||||||
GRANT USAGE ON SCHEMA device_schema TO device_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA device_schema TO device_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA device_schema TO device_service_user;
|
|
||||||
|
|
||||||
-- Messaging service
|
|
||||||
GRANT USAGE ON SCHEMA messaging_schema TO messaging_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA messaging_schema TO messaging_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA messaging_schema TO messaging_service_user;
|
|
||||||
|
|
||||||
-- Push service
|
|
||||||
GRANT USAGE ON SCHEMA push_schema TO push_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA push_schema TO push_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA push_schema TO push_service_user;
|
|
||||||
|
|
||||||
-- WebRTC service
|
|
||||||
GRANT USAGE ON SCHEMA webrtc_schema TO webrtc_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA webrtc_schema TO webrtc_service_user;
|
|
||||||
|
|
||||||
-- Moderation service
|
|
||||||
GRANT USAGE ON SCHEMA moderation_schema TO moderation_service_user;
|
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA moderation_schema TO moderation_service_user;
|
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA moderation_schema TO moderation_service_user;
|
|
||||||
|
|
||||||
-- Set default privileges for future objects
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA account_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO account_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA profile_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO profile_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA device_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO device_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA messaging_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO messaging_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA push_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO push_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA webrtc_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO webrtc_service_user;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA moderation_schema GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO moderation_service_user;
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
-- Create tables for FromChat microservices
|
|
||||||
-- This script creates the necessary tables in their respective schemas
|
|
||||||
|
|
||||||
-- Note: In production, tables will be created by Alembic migrations
|
|
||||||
-- This script provides a fallback or reference for manual setup
|
|
||||||
|
|
||||||
-- Account schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS account_schema.users (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
username VARCHAR(50) UNIQUE NOT NULL,
|
|
||||||
hashed_password VARCHAR(255) NOT NULL,
|
|
||||||
salt VARCHAR(64) NOT NULL,
|
|
||||||
display_name VARCHAR(100),
|
|
||||||
bio TEXT,
|
|
||||||
avatar_url VARCHAR(255),
|
|
||||||
is_online BOOLEAN DEFAULT FALSE,
|
|
||||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
verified BOOLEAN DEFAULT FALSE,
|
|
||||||
verification_token VARCHAR(255),
|
|
||||||
reset_token VARCHAR(255),
|
|
||||||
reset_token_expires TIMESTAMP,
|
|
||||||
two_factor_enabled BOOLEAN DEFAULT FALSE,
|
|
||||||
two_factor_secret VARCHAR(255),
|
|
||||||
login_attempts INTEGER DEFAULT 0,
|
|
||||||
locked_until TIMESTAMP,
|
|
||||||
public_key TEXT,
|
|
||||||
private_key TEXT,
|
|
||||||
encryption_enabled BOOLEAN DEFAULT FALSE,
|
|
||||||
suspended BOOLEAN DEFAULT FALSE,
|
|
||||||
suspension_reason TEXT,
|
|
||||||
deleted BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Profile schema tables (references account_schema.users)
|
|
||||||
CREATE TABLE IF NOT EXISTS profile_schema.user_profiles (
|
|
||||||
user_id BIGINT PRIMARY KEY REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
display_name VARCHAR(100),
|
|
||||||
bio TEXT,
|
|
||||||
avatar_url VARCHAR(255),
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Device schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS device_schema.devices (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
device_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
device_name VARCHAR(255),
|
|
||||||
device_type VARCHAR(50),
|
|
||||||
public_key TEXT,
|
|
||||||
signed_prekey TEXT,
|
|
||||||
one_time_prekeys JSONB,
|
|
||||||
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS device_schema.device_session (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
raw_user_agent TEXT,
|
|
||||||
device_name VARCHAR(128),
|
|
||||||
device_type VARCHAR(32),
|
|
||||||
os_name VARCHAR(64),
|
|
||||||
os_version VARCHAR(64),
|
|
||||||
browser_name VARCHAR(64),
|
|
||||||
browser_version VARCHAR(64),
|
|
||||||
brand VARCHAR(64),
|
|
||||||
model VARCHAR(64),
|
|
||||||
session_id VARCHAR(64) UNIQUE NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
revoked BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Messaging schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS messaging_schema.messages (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
sender_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
content_type VARCHAR(50) DEFAULT 'text',
|
|
||||||
encrypted_content TEXT,
|
|
||||||
signature TEXT,
|
|
||||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
edited_at TIMESTAMP,
|
|
||||||
edited BOOLEAN DEFAULT FALSE,
|
|
||||||
deleted BOOLEAN DEFAULT FALSE,
|
|
||||||
reply_to_id BIGINT REFERENCES messaging_schema.messages(id),
|
|
||||||
thread_id BIGINT REFERENCES messaging_schema.messages(id),
|
|
||||||
is_public BOOLEAN DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messaging_schema.message_recipients (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
|
||||||
recipient_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
read_at TIMESTAMP,
|
|
||||||
delivered_at TIMESTAMP,
|
|
||||||
encrypted_key TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS messaging_schema.message_reactions (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
message_id BIGINT REFERENCES messaging_schema.messages(id) ON DELETE CASCADE,
|
|
||||||
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
reaction VARCHAR(50) NOT NULL,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Push schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS push_schema.push_subscriptions (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
user_id BIGINT REFERENCES account_schema.users(id) ON DELETE CASCADE,
|
|
||||||
device_id BIGINT REFERENCES device_schema.devices(id),
|
|
||||||
endpoint VARCHAR(500) NOT NULL,
|
|
||||||
p256dh VARCHAR(255) NOT NULL,
|
|
||||||
auth VARCHAR(255) NOT NULL,
|
|
||||||
user_agent VARCHAR(500),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- WebRTC schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS webrtc_schema.webrtc_sessions (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
session_id VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
initiator_id BIGINT REFERENCES account_schema.users(id),
|
|
||||||
participant_ids JSONB NOT NULL,
|
|
||||||
offer JSONB,
|
|
||||||
answer JSONB,
|
|
||||||
ice_candidates JSONB,
|
|
||||||
status VARCHAR(50) DEFAULT 'pending',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Moderation schema tables
|
|
||||||
CREATE TABLE IF NOT EXISTS moderation_schema.moderation_actions (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
moderator_id BIGINT REFERENCES account_schema.users(id),
|
|
||||||
target_user_id BIGINT REFERENCES account_schema.users(id),
|
|
||||||
target_message_id BIGINT REFERENCES messaging_schema.messages(id),
|
|
||||||
action_type VARCHAR(50) NOT NULL,
|
|
||||||
reason TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Grant permissions on sequences (after all tables are created)
|
|
||||||
GRANT USAGE ON SEQUENCE account_schema.users_id_seq TO account_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE device_schema.devices_id_seq TO device_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE device_schema.device_session_id_seq TO account_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE messaging_schema.messages_id_seq TO messaging_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE messaging_schema.message_recipients_id_seq TO messaging_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE messaging_schema.message_reactions_id_seq TO messaging_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE push_schema.push_subscriptions_id_seq TO push_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE webrtc_schema.webrtc_sessions_id_seq TO webrtc_service_user;
|
|
||||||
GRANT USAGE ON SEQUENCE moderation_schema.moderation_actions_id_seq TO moderation_service_user;
|
|
||||||
@@ -1,368 +1,67 @@
|
|||||||
services:
|
services:
|
||||||
# Database service
|
backend:
|
||||||
database:
|
|
||||||
image: postgres:15
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: fromchat
|
|
||||||
POSTGRES_USER: fromchat_admin
|
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
||||||
volumes:
|
|
||||||
- database:/var/lib/postgresql/data
|
|
||||||
- ./db-init:/docker-entrypoint-initdb.d
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U fromchat_admin -d fromchat"]
|
|
||||||
interval: 1s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
# Migration runner - runs once before other services
|
|
||||||
migration_runner:
|
|
||||||
build:
|
build:
|
||||||
|
dockerfile: deployment/Dockerfile.backend
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: migration_runner
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://fromchat_admin:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
depends_on:
|
|
||||||
database:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/alembic.ini
|
|
||||||
target: /app/backend/alembic.ini
|
|
||||||
- action: sync
|
|
||||||
path: backend/alembic
|
|
||||||
target: /app/backend/alembic
|
|
||||||
- action: sync
|
|
||||||
path: backend/migration.py
|
|
||||||
target: /app/backend/migration.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/migration_runner
|
|
||||||
target: /app/backend/services/migration_runner
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Gateway service - handles complex operations
|
|
||||||
gateway:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: gateway
|
|
||||||
ports: ["8300:8300"]
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://gateway_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
PORT: 8300
|
PORT: 8300
|
||||||
ACCOUNT_SERVICE_URL: http://account_service:8302
|
|
||||||
PROFILE_SERVICE_URL: http://profile_service:8303
|
|
||||||
DEVICE_SERVICE_URL: http://device_service:8304
|
|
||||||
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
|
||||||
PUSH_SERVICE_URL: http://push_service:8306
|
|
||||||
WEBRTC_SERVICE_URL: http://webrtc_service:8307
|
|
||||||
MODERATION_SERVICE_URL: http://moderation_service:8308
|
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
- fromchat_external
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/app.py
|
|
||||||
target: /app/backend/app.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/main.py
|
|
||||||
target: /app/backend/main.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/dependencies.py
|
|
||||||
target: /app/backend/dependencies.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/security
|
|
||||||
target: /app/backend/security
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/gateway
|
|
||||||
target: /app/backend/services/gateway
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Account service
|
|
||||||
account_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: account_service
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://account_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
PORT: 8302
|
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/routes/account.py
|
|
||||||
target: /app/backend/routes/account.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/account
|
|
||||||
target: /app/backend/services/account
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Profile service
|
|
||||||
profile_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: profile_service
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://profile_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||||
MESSAGING_SERVICE_URL: http://messaging_service:8305
|
FIREBASE_CERT: ${FIREBASE_CERT}
|
||||||
PORT: 8303
|
volumes:
|
||||||
depends_on:
|
- data:/app/data
|
||||||
migration_runner:
|
- logs:/app/logs
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: sync
|
|
||||||
path: backend/routes/profile.py
|
|
||||||
target: /app/backend/routes/profile.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/profile
|
|
||||||
target: /app/backend/services/profile
|
|
||||||
- action: sync+restart
|
- action: sync+restart
|
||||||
path: backend/shared
|
path: ../backend
|
||||||
target: /app/backend/shared
|
target: /app
|
||||||
|
- action: rebuild
|
||||||
|
path: ../backend/requirements.txt
|
||||||
|
|
||||||
# Device service
|
frontend:
|
||||||
device_service:
|
|
||||||
build:
|
build:
|
||||||
|
dockerfile: deployment/frontend/Dockerfile
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: device_service
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://device_service_user:${DB_PASSWORD}@database:5432/fromchat
|
PORT: 8301
|
||||||
PORT: 8304
|
BACKEND_HOST: http://backend:8300
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/routes/devices.py
|
|
||||||
target: /app/backend/routes/devices.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/device
|
|
||||||
target: /app/backend/services/device
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Messaging service
|
|
||||||
messaging_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: messaging_service
|
|
||||||
ports:
|
ports:
|
||||||
- "8305:8305"
|
- "8301:8301"
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://messaging_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
|
||||||
PUSH_SERVICE_URL: http://push_service:8306
|
|
||||||
PORT: 8305
|
|
||||||
depends_on:
|
depends_on:
|
||||||
migration_runner:
|
- backend
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- action: sync
|
- action: rebuild
|
||||||
path: backend/routes/messaging.py
|
path: ../frontend
|
||||||
target: /app/backend/routes/messaging.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/websocket
|
|
||||||
target: /app/backend/websocket
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/messaging
|
|
||||||
target: /app/backend/services/messaging
|
|
||||||
- action: sync+restart
|
- action: sync+restart
|
||||||
path: backend/shared
|
path: server.js
|
||||||
target: /app/backend/shared
|
target: /server/server.js
|
||||||
|
- action: rebuild
|
||||||
|
path: package.json
|
||||||
|
|
||||||
# Push service
|
|
||||||
push_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: push_service
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://push_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
FIREBASE_CERT: ${FIREBASE_CERT}
|
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
|
||||||
PORT: 8306
|
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/routes/push.py
|
|
||||||
target: /app/backend/routes/push.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/push_service.py
|
|
||||||
target: /app/backend/push_service.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/push
|
|
||||||
target: /app/backend/services/push
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# WebRTC service
|
|
||||||
webrtc_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: webrtc_service
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://webrtc_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
PORT: 8307
|
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/routes/webrtc.py
|
|
||||||
target: /app/backend/routes/webrtc.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/webrtc
|
|
||||||
target: /app/backend/services/webrtc
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Moderation service
|
|
||||||
moderation_service:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile.multi
|
|
||||||
target: moderation_service
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://moderation_service_user:${DB_PASSWORD}@database:5432/fromchat
|
|
||||||
PORT: 8308
|
|
||||||
depends_on:
|
|
||||||
migration_runner:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks:
|
|
||||||
- fromchat_internal
|
|
||||||
restart: unless-stopped
|
|
||||||
develop:
|
|
||||||
watch:
|
|
||||||
- action: sync
|
|
||||||
path: backend/routes/moderation.py
|
|
||||||
target: /app/backend/routes/moderation.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/security
|
|
||||||
target: /app/backend/security
|
|
||||||
- action: sync
|
|
||||||
path: backend/similarity.py
|
|
||||||
target: /app/backend/similarity.py
|
|
||||||
- action: sync
|
|
||||||
path: backend/services/moderation
|
|
||||||
target: /app/backend/services/moderation
|
|
||||||
- action: sync+restart
|
|
||||||
path: backend/shared
|
|
||||||
target: /app/backend/shared
|
|
||||||
|
|
||||||
# Caddy reverse proxy
|
|
||||||
caddy:
|
caddy:
|
||||||
build:
|
build:
|
||||||
context: ./caddy
|
context: ./caddy
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
|
||||||
- certs:/root/site/certs
|
|
||||||
networks:
|
|
||||||
- fromchat_external
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- certs:/root/site/certs
|
||||||
environment:
|
environment:
|
||||||
- XDG_DATA_HOME=/root/site/certs
|
XDG_DATA_HOME: /root/site/certs
|
||||||
- XDG_CONFIG_HOME=/root/site/certs
|
XDG_CONFIG_HOME: /root/site/certs
|
||||||
restart: unless-stopped
|
|
||||||
depends_on:
|
|
||||||
- gateway
|
|
||||||
profiles: ["prod"]
|
|
||||||
|
|
||||||
# Frontend service - serves the React app
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: deployment/frontend/Dockerfile
|
|
||||||
ports:
|
|
||||||
- "8301:8301"
|
|
||||||
environment:
|
|
||||||
- PORT=8301
|
|
||||||
- BACKEND_HOST=http://gateway:8300
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- fromchat_external
|
|
||||||
- fromchat_internal
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
database:
|
|
||||||
name: fromchat-database
|
|
||||||
certs:
|
|
||||||
name: fromchat-certs
|
|
||||||
logs:
|
|
||||||
name: fromchat-logs
|
|
||||||
data:
|
data:
|
||||||
name: fromchat-data
|
name: fromchat-data
|
||||||
|
logs:
|
||||||
networks:
|
name: fromchat-logs
|
||||||
fromchat_internal:
|
certs:
|
||||||
driver: bridge
|
name: fromchat-certs
|
||||||
internal: true
|
|
||||||
fromchat_external:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,44 +1,28 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT) || 8301;
|
const port = process.env.PORT || 3000;
|
||||||
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
const backendHost = process.env.BACKEND_HOST || "http://localhost:8300";
|
||||||
const filePath = process.env.STATIC_FILE_PATH || ".";
|
const filePath = process.env.STATIC_FILE_PATH || ".";
|
||||||
|
|
||||||
// Direct WebSocket proxy for chat - bypass gateway (must come before general API proxy)
|
// API proxy middleware
|
||||||
app.use('/api/chat/ws', createProxyMiddleware({
|
app.use('/api', createProxyMiddleware({
|
||||||
target: 'http://messaging_service:8305',
|
|
||||||
changeOrigin: true,
|
|
||||||
pathRewrite: { '^/api/chat/ws': '/messaging/chat/ws' },
|
|
||||||
ws: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
// API proxy middleware (exclude WebSocket paths)
|
|
||||||
app.use('/api', (req, res, next) => {
|
|
||||||
// Skip WebSocket upgrade requests - let them be handled by specific proxies
|
|
||||||
if (req.headers.upgrade === 'websocket') {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
createProxyMiddleware({
|
|
||||||
target: backendHost,
|
target: backendHost,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
pathRewrite: { '^/api': '' },
|
pathRewrite: { '^/api': '' },
|
||||||
ws: true
|
ws: true
|
||||||
})(req, res, next);
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
// Serve static files
|
// Serve static files
|
||||||
app.use(express.static(resolve(filePath)));
|
app.use(express.static(resolve(filePath)));
|
||||||
|
|
||||||
// SPA routing - catch all handler for client-side routing
|
// SPA routing - catch all handler for client-side routing
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req, res) => {
|
||||||
res.sendFile(resolve(filePath, 'index.html'));
|
res.sendFile(resolve(filePath, 'index.html'));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(port, '0.0.0.0', () => {
|
app.listen(port, () => {
|
||||||
console.log(`Backend host: ${backendHost}`);
|
console.log(`Server launched on http://localhost:${port}`);
|
||||||
console.log(`Server launched on http://0.0.0.0:${port}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
# Multi-stage Dockerfile for all FromChat microservices
|
|
||||||
# This combines the base image and all services in one file
|
|
||||||
|
|
||||||
# Base stage - common setup for all services
|
|
||||||
FROM python:3.11-slim AS base
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
libpq5 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN useradd --create-home --shell /bin/bash fromchat
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Create logs and data directories with proper permissions
|
|
||||||
RUN mkdir -p /app/backend/logs /app/backend/data /app/backend/data/profanity /app/backend/data/uploads /app/backend/data/uploads/pfp && \
|
|
||||||
chown -R fromchat:fromchat /app/backend/logs /app/backend/data && \
|
|
||||||
chmod -R 755 /app/backend/logs /app/backend/data
|
|
||||||
|
|
||||||
# Copy requirements first for better caching
|
|
||||||
COPY backend/requirements.txt /app/requirements.txt
|
|
||||||
|
|
||||||
# Install Python dependencies with cache mounts
|
|
||||||
RUN --mount=type=cache,target=/home/fromchat/.cache/pip \
|
|
||||||
pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy shared modules
|
|
||||||
COPY backend/shared /app/backend/shared/
|
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker/entrypoint.sh /app/entrypoint.sh
|
|
||||||
RUN chmod +x /app/entrypoint.sh
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER fromchat
|
|
||||||
|
|
||||||
# Set entrypoint
|
|
||||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
|
||||||
|
|
||||||
# Account service - minimal files only
|
|
||||||
FROM base AS account_service
|
|
||||||
COPY backend/routes/account.py /app/backend/routes/account.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
|
||||||
COPY backend/services/account/main.py /app/backend/services/account/main.py
|
|
||||||
ENV SERVICE_NAME=account
|
|
||||||
|
|
||||||
# Profile service - minimal files only
|
|
||||||
FROM base AS profile_service
|
|
||||||
COPY backend/routes/profile.py /app/backend/routes/profile.py
|
|
||||||
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
|
||||||
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
|
||||||
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
|
||||||
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
|
||||||
COPY backend/services/profile/main.py /app/backend/services/profile/main.py
|
|
||||||
ENV SERVICE_NAME=profile
|
|
||||||
|
|
||||||
# Device service - minimal files only
|
|
||||||
FROM base AS device_service
|
|
||||||
COPY backend/routes/devices.py /app/backend/routes/devices.py
|
|
||||||
COPY backend/services/device/main.py /app/backend/services/device/main.py
|
|
||||||
ENV SERVICE_NAME=device
|
|
||||||
|
|
||||||
# Messaging service - minimal files only
|
|
||||||
FROM base AS messaging_service
|
|
||||||
COPY backend/routes/messaging.py /app/backend/routes/messaging.py
|
|
||||||
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
|
||||||
COPY backend/services/messaging/files/websocket /app/backend/services/messaging/files/websocket/
|
|
||||||
COPY backend/services/messaging/main.py /app/backend/services/messaging/main.py
|
|
||||||
ENV SERVICE_NAME=messaging
|
|
||||||
|
|
||||||
# Push service - minimal files only
|
|
||||||
FROM base AS push_service
|
|
||||||
COPY backend/routes/push.py /app/backend/routes/push.py
|
|
||||||
COPY backend/services/push/files/push_service.py /app/backend/services/push/files/push_service.py
|
|
||||||
COPY backend/services/push/main.py /app/backend/services/push/main.py
|
|
||||||
ENV SERVICE_NAME=push
|
|
||||||
|
|
||||||
# WebRTC service - minimal files only
|
|
||||||
FROM base AS webrtc_service
|
|
||||||
COPY backend/routes/webrtc.py /app/backend/routes/webrtc.py
|
|
||||||
COPY backend/services/webrtc/main.py /app/backend/services/webrtc/main.py
|
|
||||||
ENV SERVICE_NAME=webrtc
|
|
||||||
|
|
||||||
# Moderation service - minimal files only
|
|
||||||
FROM base AS moderation_service
|
|
||||||
COPY backend/routes/moderation.py /app/backend/routes/moderation.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/shared/similarity.py /app/backend/shared/similarity.py
|
|
||||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
|
||||||
COPY backend/services/moderation/main.py /app/backend/services/moderation/main.py
|
|
||||||
ENV SERVICE_NAME=moderation
|
|
||||||
|
|
||||||
# Gateway service - minimal files only
|
|
||||||
FROM base AS gateway
|
|
||||||
COPY backend/app.py /app/backend/app.py
|
|
||||||
COPY backend/main.py /app/backend/main.py
|
|
||||||
COPY backend/dependencies.py /app/backend/dependencies.py
|
|
||||||
COPY backend/logging_config.py /app/backend/logging_config.py
|
|
||||||
COPY backend/security /app/backend/security/
|
|
||||||
COPY backend/services/gateway/main.py /app/backend/services/gateway/main.py
|
|
||||||
ENV SERVICE_NAME=gateway
|
|
||||||
|
|
||||||
# Migration runner - needs alembic config and migration files
|
|
||||||
FROM base AS migration_runner
|
|
||||||
# Temporarily switch back to root to manage file permissions
|
|
||||||
USER root
|
|
||||||
COPY backend/alembic.ini /app/backend/alembic.ini
|
|
||||||
COPY backend/alembic /app/backend/alembic/
|
|
||||||
COPY backend/migration.py /app/backend/migration.py
|
|
||||||
COPY backend/services/migration_runner/main.py /app/backend/services/migration_runner/main.py
|
|
||||||
# Clean up problematic migrations as root
|
|
||||||
RUN find /app/backend/alembic/versions -name "*auto_generated_migration_for_schema_*" | xargs rm -f || true
|
|
||||||
# Switch back to fromchat user
|
|
||||||
USER fromchat
|
|
||||||
ENV SERVICE_NAME=migration_runner
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Entrypoint script for FromChat microservices
|
|
||||||
|
|
||||||
# Run the service module
|
|
||||||
exec python -m backend.services.${SERVICE_NAME}.main
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
|
||||||
|
import useWindowSize from "@/core/hooks/useWindowSize";
|
||||||
|
import styles from "./css/split-button.module.scss";
|
||||||
|
|
||||||
|
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
|
||||||
|
|
||||||
|
interface SplitButtonProps {
|
||||||
|
text: ReactNode;
|
||||||
|
icon?: ReactNode | string;
|
||||||
|
menu: ReactNode;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onMenuOpen: (open: boolean) => void;
|
||||||
|
onPrimaryClick?: () => void;
|
||||||
|
variant?: SplitButtonVariant;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
menuAriaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitButton({
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
menu,
|
||||||
|
menuOpen: open,
|
||||||
|
onMenuOpen,
|
||||||
|
onPrimaryClick,
|
||||||
|
variant = "filled",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
menuAriaLabel,
|
||||||
|
}: SplitButtonProps) {
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
|
const primaryRipple = useRippleHandlers(disabled);
|
||||||
|
const menuRipple = useRippleHandlers(disabled);
|
||||||
|
|
||||||
|
const MENU_GAP = 16;
|
||||||
|
const EDGE_PAD = 16;
|
||||||
|
|
||||||
|
const updateMenuPosition = useCallback(() => {
|
||||||
|
const anchor = menuSegmentRef.current;
|
||||||
|
if (!anchor) return;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const menuEl = menuRef.current;
|
||||||
|
const menuWidth = menuEl?.offsetWidth ?? 220;
|
||||||
|
const menuHeight = menuEl?.offsetHeight ?? 320;
|
||||||
|
|
||||||
|
const anchorCenterX = rect.left + rect.width / 2;
|
||||||
|
let left: number;
|
||||||
|
let top: number | undefined;
|
||||||
|
let bottom: number | undefined;
|
||||||
|
let maxHeight: number;
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
|
||||||
|
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
|
||||||
|
const fitsBelow = menuHeight <= availableBelow;
|
||||||
|
const fitsAbove = menuHeight <= availableAbove;
|
||||||
|
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
|
||||||
|
|
||||||
|
if (placeAbove) {
|
||||||
|
bottom = vh - (rect.top - MENU_GAP);
|
||||||
|
maxHeight = Math.max(100, availableAbove);
|
||||||
|
} else {
|
||||||
|
top = rect.bottom + MENU_GAP;
|
||||||
|
maxHeight = Math.max(100, availableBelow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
|
||||||
|
left = EDGE_PAD;
|
||||||
|
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
|
||||||
|
left = vw - menuWidth - EDGE_PAD;
|
||||||
|
} else {
|
||||||
|
left = anchorCenterX - menuWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuPosition({ top, bottom, left, maxHeight });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
onMenuOpen(false);
|
||||||
|
setIsExiting(true);
|
||||||
|
}, [onMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && !isExiting) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
updateMenuPosition();
|
||||||
|
window.addEventListener("scroll", updateMenuPosition, true);
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (rootRef.current?.contains(target)) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setIsExiting(true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open && menuRef.current) {
|
||||||
|
updateMenuPosition();
|
||||||
|
}
|
||||||
|
}, [open, updateMenuPosition]);
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrimaryClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (open) closeMenu();
|
||||||
|
else onMenuOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClass =
|
||||||
|
variant === "tonal"
|
||||||
|
? styles.variantTonal
|
||||||
|
: variant === "outlined"
|
||||||
|
? styles.variantOutlined
|
||||||
|
: variant === "elevated"
|
||||||
|
? styles.variantElevated
|
||||||
|
: styles.variantFilled;
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof icon === "string") {
|
||||||
|
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={styles.leadingIconIcon}>{icon}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootClasses = [
|
||||||
|
styles.splitButton,
|
||||||
|
variantClass,
|
||||||
|
disabled ? styles.disabled : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={rootClasses}
|
||||||
|
data-open={open ? "true" : "false"}
|
||||||
|
aria-disabled={disabled ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primarySegment}
|
||||||
|
onClick={handlePrimaryClick}
|
||||||
|
onPointerDown={primaryRipple.onPointerDown}
|
||||||
|
onPointerEnter={primaryRipple.onPointerEnter}
|
||||||
|
onPointerLeave={primaryRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={primaryRipple.rippleRef} />
|
||||||
|
<span className={styles.primaryContent}>
|
||||||
|
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
|
||||||
|
<span className={styles.label}>{text}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={menuSegmentRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.menuSegment}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
onPointerDown={menuRipple.onPointerDown}
|
||||||
|
onPointerEnter={menuRipple.onPointerEnter}
|
||||||
|
onPointerLeave={menuRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={menuAriaLabel}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={menuRipple.rippleRef} />
|
||||||
|
<span className={styles.menuIcon}>
|
||||||
|
<MaterialIcon name="expand_more" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(open || isExiting) &&
|
||||||
|
menuPosition &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="menu"
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.menu}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
...(menuPosition.bottom != null
|
||||||
|
? { bottom: menuPosition.bottom }
|
||||||
|
: { top: menuPosition.top }),
|
||||||
|
left: menuPosition.left,
|
||||||
|
maxHeight: menuPosition.maxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{menu}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
@use "../../../css/material" as *;
|
||||||
|
|
||||||
|
$height: 40px;
|
||||||
|
$trailing-width: 48px; // 12 + 22 + 14 per spec
|
||||||
|
$between-space: 2px;
|
||||||
|
$outer-radius: calc(#{$height} / 2); // 20px
|
||||||
|
$inner-radius: 4px;
|
||||||
|
$inner-radius-hovered: 12px;
|
||||||
|
|
||||||
|
.splitButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $outer-radius;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment,
|
||||||
|
.menuSegment {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
min-height: $height;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $color-dark-primary;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdui-ripple {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment {
|
||||||
|
border-top-left-radius: $outer-radius;
|
||||||
|
border-bottom-left-radius: $outer-radius;
|
||||||
|
border-top-right-radius: $inner-radius;
|
||||||
|
border-bottom-right-radius: $inner-radius;
|
||||||
|
padding-inline: 16px 12px;
|
||||||
|
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.leadingIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.leadingIconIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuSegment {
|
||||||
|
width: $trailing-width;
|
||||||
|
border-top-right-radius: $outer-radius;
|
||||||
|
border-bottom-right-radius: $outer-radius;
|
||||||
|
border-top-left-radius: $inner-radius;
|
||||||
|
border-bottom-left-radius: $inner-radius;
|
||||||
|
margin-left: $between-space;
|
||||||
|
padding-inline: 12px 14px;
|
||||||
|
transition:
|
||||||
|
border-top-left-radius 0.18s ease-out,
|
||||||
|
border-bottom-left-radius 0.18s ease-out,
|
||||||
|
padding-inline 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
font-size: 22px;
|
||||||
|
transition: transform 0.18s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
mdui-icon {
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-open="true"] .menuSegment {
|
||||||
|
$size: calc($trailing-width / 2);
|
||||||
|
border-radius: $size;
|
||||||
|
padding-inline: 13px 13px;
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantFilled {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantTonal {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantOutlined {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.8);
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantElevated {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.3),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: $color-dark-surface-container-low;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$menu-padding: 8px;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding: $menu-padding;
|
||||||
|
min-width: 220px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgba($color-dark-surface-container-high, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 100000000;
|
||||||
|
|
||||||
|
// Custom slim semi-transparent scrollbar
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba($color-dark-on-surface, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba($color-dark-on-surface, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
|
||||||
|
|
||||||
|
mdui-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<line x1="1.407" y1="1.353" x2="1.407" y2="1.46" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0, 0, 0); fill-rule: nonzero; opacity: 1;"/>
|
||||||
|
<g transform="matrix(1.329817, 0, 0, 1.329817, -42.024433, -38.371166)" style="">
|
||||||
|
<g transform="matrix(1, 0, 0, 1, -0.000001, 0.000008)">
|
||||||
|
<path d="M 187.533 167.601 C 184.614 169.278 180.394 175.015 176.028 176.522 L 173.703 176.879 C 173.692 176.878 173.682 176.878 173.671 176.877 C 170.911 176.954 167.048 175.793 164.888 173.621 C 164.62 173.351 164.378 173.064 164.167 172.763 C 163.081 170.76 162.591 168.959 162.078 167.519 C 163.681 138.029 142.006 97.682 141.345 89.031 L 140.535 89.081 L 143.379 87.758 C 146.633 86.246 146.577 81.601 143.289 80.165 L 126.072 72.64 C 124.802 72.084 123.338 72.196 122.169 72.941 L 109.131 81.222 C 106.644 82.801 106.537 86.395 108.928 88.118 L 120.16 96.21 C 121.382 97.093 122.987 97.244 124.353 96.607 L 131.993 93.054 L 124.35 96.609 C 122.984 97.244 121.38 97.093 120.157 96.213 L 112.629 90.787 L 112.182 90.815 C 112.182 96.766 92.754 126.354 85.61 153.184 C 82.2 144.438 85.83 134.593 92.372 122.051 C 83.667 131.338 75.779 146.462 83.605 158.699 C 87.499 164.679 93.454 168.284 99.138 171.235 C 103.255 173.904 113.854 176.441 110.918 182.885 C 110.925 182.876 110.931 182.867 110.938 182.859 C 110.931 182.869 110.925 182.88 110.918 182.89 C 108.679 187.681 104.866 190.521 100.012 192.077 C 98.585 189.825 96.894 187.456 94.937 184.967 L 90.627 179.198 C 89.739 177.993 88.66 176.155 87.266 173.682 C 85.934 171.212 84.729 169.31 83.779 167.978 C 82.956 166.519 81.624 165.061 79.912 163.603 C 79.401 163.15 78.861 162.743 78.299 162.386 C 75.967 160.899 72.963 160.587 70.538 161.919 C 69.819 162.313 69.262 162.771 68.883 163.288 C 67.998 164.493 67.489 165.825 67.363 167.219 C 67.172 168.551 66.792 169.439 66.157 169.883 C 66.152 169.886 66.141 169.891 66.135 169.894 C 65.64 163.51 67.408 157.038 70.867 151.489 C 73.595 147.111 75.664 142.699 76.428 138.228 C 76.779 130.993 78.71 123.934 82.113 117.038 C 86.159 107.861 95.837 101.976 98.644 92.659 C 102.474 80.66 83.964 28.579 126.067 28.984 C 150.53 28.984 159.32 50.152 159.32 84.644 C 159.32 107.391 201.316 122.18 187.533 167.601 Z M 151.486 211.429 C 151.801 212.025 152.146 212.581 152.52 213.101 C 136.094 207.361 120.468 206.548 104.498 212.435 C 105.326 211.043 105.778 209.435 105.778 207.781 C 105.778 207.779 105.778 207.778 105.778 207.776 C 105.778 207.761 105.778 207.746 105.778 207.731 C 105.778 207.729 105.778 207.727 105.778 207.725 C 105.778 204.739 104.88 201.359 103.085 197.582 C 107.83 198.93 113.536 199.677 120.393 199.677 C 136.171 200.012 146.507 196.345 152.958 190.163 C 151.666 200.295 149.855 207.938 151.486 211.429 Z M 168.963 155.718 C 167.694 156.465 166.457 157.278 165.336 158.247 C 164.063 159.306 162.925 160.573 162.122 161.99 C 164.732 160.051 167.618 158.932 170.555 157.952 L 170.855 157.851 C 172.058 156.221 173.112 154.49 173.983 152.649 C 179.496 140.288 172.1 126.454 162.265 118.749 C 168.908 130.233 175.535 143.047 168.963 155.718 Z M 110.325 80.46 L 121.571 73.32 C 121.829 72.168 121.972 70.94 121.972 69.664 C 121.972 63.111 118.283 57.8 113.731 57.8 C 109.179 57.8 105.489 63.111 105.489 69.664 C 105.489 74.469 107.476 78.594 110.325 80.46 Z M 140.237 78.831 C 142.075 76.656 143.244 73.357 143.244 69.664 C 143.244 63.111 139.555 57.8 135.002 57.8 C 130.45 57.8 126.761 63.111 126.761 69.664 C 126.761 70.859 126.887 72.008 127.115 73.095 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.673 C 97.691 188.648 97.674 188.626 97.658 188.6 C 97.677 188.626 97.691 188.651 97.708 188.673 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 178.569 117.583 L 162.265 118.749 C 173.339 131.799 177.532 144.523 169.998 156.693 C 166.446 158.264 163.822 160.028 162.122 161.987 C 162.189 163.875 162.178 165.735 162.08 167.551 C 161.65 166.34 161.203 165.379 160.377 164.766 C 160.374 164.774 160.368 164.786 160.363 164.794 C 161.832 165.882 162.13 168.073 163.274 170.855 C 163.316 170.956 163.353 171.052 163.397 171.156 C 163.619 171.676 163.867 172.21 164.167 172.763 C 166.07 175.489 170.571 176.97 173.676 176.883 C 174.039 176.905 174.404 176.88 174.767 176.829 C 174.882 176.812 175 176.781 175.115 176.759 C 175.365 176.708 175.615 176.649 175.863 176.571 C 175.995 176.529 176.124 176.481 176.256 176.43 C 176.495 176.34 176.734 176.236 176.973 176.124 C 177.099 176.065 177.223 176.006 177.349 175.941 C 177.616 175.801 177.883 175.646 178.147 175.48 C 178.237 175.424 178.327 175.376 178.414 175.317 C 178.768 175.087 179.122 174.84 179.471 174.576 C 179.532 174.531 179.591 174.48 179.651 174.432 C 179.943 174.208 180.229 173.977 180.516 173.738 C 180.628 173.646 180.738 173.55 180.848 173.454 C 181.086 173.249 181.325 173.041 181.558 172.831 C 181.668 172.732 181.778 172.631 181.887 172.533 C 182.14 172.302 182.39 172.069 182.638 171.836 C 182.713 171.766 182.789 171.693 182.865 171.622 C 183.202 171.302 183.537 170.987 183.863 170.675 C 184.141 170.411 184.413 170.153 184.68 169.903 C 184.773 169.818 184.86 169.737 184.95 169.655 C 185.155 169.467 185.358 169.282 185.557 169.107 C 185.658 169.017 185.759 168.933 185.861 168.849 C 186.043 168.694 186.223 168.545 186.397 168.408 C 186.496 168.329 186.594 168.253 186.69 168.18 C 186.869 168.045 187.046 167.924 187.218 167.812 C 187.297 167.761 187.378 167.705 187.454 167.66 C 187.482 167.643 187.513 167.621 187.538 167.604 C 194.434 144.871 187.358 129.81 178.569 117.583 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.676 C 97.708 188.676 97.708 188.676 97.708 188.676 C 97.711 188.679 97.711 188.682 97.714 188.685 C 97.714 188.685 97.714 188.685 97.714 188.685 C 98.54 189.845 99.296 190.975 99.995 192.079 C 104.857 190.525 108.676 187.684 110.918 182.888 C 119.916 168.72 59.028 169.675 92.372 122.051 L 80.221 121.306 C 79.123 124.043 78.257 126.806 77.622 129.593 C 77.611 129.638 77.6 129.683 77.589 129.728 C 77.299 131.021 77.069 132.322 76.883 133.628 C 76.849 133.864 76.821 134.103 76.79 134.339 C 76.625 135.629 76.493 136.924 76.431 138.228 C 75.666 142.699 73.598 147.111 70.87 151.489 C 70.46 152.149 70.075 152.823 69.715 153.506 C 69.414 154.08 69.136 154.661 68.872 155.248 C 68.827 155.344 68.776 155.44 68.734 155.535 C 68.127 156.915 67.616 158.328 67.208 159.767 C 66.273 163.066 65.876 166.488 66.141 169.894 C 66.146 169.891 66.157 169.886 66.163 169.883 C 66.798 169.439 67.177 168.551 67.369 167.219 C 67.495 165.825 68.004 164.493 68.889 163.288 C 69.268 162.771 69.824 162.313 70.544 161.919 C 72.969 160.587 75.973 160.899 78.305 162.386 C 78.867 162.743 79.407 163.15 79.918 163.603 C 81.629 165.061 82.961 166.519 83.785 167.978 C 84.734 169.31 85.94 171.212 87.272 173.682 C 88.666 176.155 89.745 177.993 90.633 179.198 L 94.943 184.967 C 95.688 185.914 96.373 186.836 97.037 187.746 C 97.261 188.058 97.492 188.37 97.708 188.676 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 122.169 72.941 C 118.834 75.093 115.509 77.26 112.241 79.513 L 109.8 81.219 C 107.29 82.543 107.108 86.283 109.592 87.601 C 110.213 88.014 120.685 94.772 120.823 94.921 C 121.686 95.556 122.672 95.629 123.597 95.235 C 123.597 95.235 128.913 92.698 128.913 92.698 C 132.521 91.046 136.14 89.416 139.799 87.879 C 140.951 87.263 143.674 86.679 144.295 85.44 C 145.102 84.229 144.885 82.292 143.618 81.301 C 140.94 79.665 136.031 77.493 133.153 75.984 C 133.153 75.984 127.767 73.449 127.767 73.449 C 126.033 72.567 123.979 71.716 122.169 72.941 Z M 122.169 72.941 C 123.948 71.676 126.072 72.486 127.817 73.337 C 127.817 73.337 133.339 75.568 133.339 75.568 C 136.525 76.889 141.575 78.485 144.585 79.985 C 147.94 82.014 147.237 87.637 143.626 89.039 C 139.26 91.341 134.738 93.535 130.282 95.634 C 127.727 96.584 123.675 99.481 120.826 98.191 C 118.682 97.396 110.668 90.321 108.647 88.91 C 105.613 86.881 106.132 81.958 109.417 80.618 C 109.417 80.618 112 79.134 112 79.134 C 115.428 77.13 118.803 75.043 122.169 72.941 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 137.309 83.658 C 129.815 90.793 116.833 92.487 108.451 85.83 C 118.092 89.511 128.081 88.126 137.309 83.658 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 135.177 76.619 C 136.199 75.787 136.995 74.354 137.234 72.634 C 137.638 69.737 136.337 67.158 134.322 66.877 C 132.31 66.596 130.349 68.717 129.942 71.614 C 129.801 72.612 129.877 73.562 130.102 74.402 L 135.177 76.619 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 115.226 77.35 L 118.477 75.284 C 118.94 74.267 119.14 72.98 118.949 71.612 C 118.544 68.715 116.583 66.593 114.568 66.874 C 112.556 67.155 111.252 69.735 111.657 72.632 C 112.008 75.138 113.523 77.055 115.226 77.35 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 59.565 208.06 C 67.843 208.624 75.959 211.078 83.813 213.494 C 87.536 214.444 91.254 216.223 95.12 215.916 C 110.114 213.418 101.209 197.528 95.48 190.025 C 91.948 185.386 87.606 179.844 84.661 174.59 C 82.773 171.676 80.913 167.472 77.864 165.353 C 76.043 163.476 71.974 162.855 70.552 165.308 C 69.26 167.528 70.055 170.085 67.534 171.853 C 66.008 172.746 64.775 172.898 63.246 173.241 C 63.03 173.227 61.69 173.283 61.453 173.275 C 58.157 173.502 53.65 172.238 52.563 176.27 C 51.548 180.165 53.532 184.234 53.389 188.345 C 53.512 192.045 51.677 195.729 49.702 198.539 C 48.876 199.939 48.05 201.462 47.923 202.836 C 47.923 202.836 47.895 202.625 47.895 202.625 C 48.39 204.522 51 205.533 52.799 206.213 C 54.976 206.989 57.264 207.565 59.565 208.043 C 57.219 207.877 54.858 207.683 52.526 207.127 C 50.104 206.433 47.33 205.812 46.257 203.049 C 46.176 198.978 48.98 195.946 49.725 192.984 C 51.425 187.937 48.719 183.45 48.387 178.296 C 48.379 169.56 54.704 168.554 61.099 168.652 C 61.099 168.652 62.988 168.554 62.988 168.554 L 62.49 168.607 C 63.215 168.411 64.415 168.155 64.924 167.826 C 64.362 168.315 64.946 167.716 64.949 166.739 C 65.267 162.259 69.116 158.446 73.638 158.494 C 76.42 158.37 79.069 159.514 81.166 161.259 C 84.762 163.752 87.005 168.211 89.362 171.945 C 94.294 181.041 102.184 188.401 106.051 198.258 C 110.586 208.352 107.428 218.445 95.457 219.623 C 91.046 219.825 86.937 217.515 82.888 216.127 C 75.248 212.887 67.68 209.875 59.565 208.06 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 173.676 176.883 C 170.04 177.085 166.101 176.093 163.628 173.137 C 162.616 171.504 161.905 169.658 161.242 167.975 C 160.756 166.418 159.579 165.201 158.46 165.297 C 154.203 166.106 156.179 175.222 155.81 178.734 C 155.754 184.413 155.22 190.07 154.523 195.628 C 154.133 200.068 152.424 206.837 153.947 210.561 C 155.895 214.048 159.787 216.161 163.743 215.908 C 166.519 215.635 168.883 213.823 171.541 212.008 C 178.285 206.981 184.38 201.231 191.84 196.904 C 194.948 195.108 198.368 193.566 201.861 192.501 C 203.075 192.231 204.952 190.952 205.103 190.143 C 205.053 189.705 204.471 189.193 204.145 188.879 C 201.195 186.423 195.49 184.627 193.473 182.365 C 190.983 180.314 189.73 176.486 189.986 173.477 C 189.927 170.948 189.772 167.112 188.134 168.394 C 183.601 170.858 179.6 177.209 173.676 176.883 C 176.63 176.759 178.805 174.629 180.834 172.746 C 183.436 170.431 189.337 161.785 191.947 168.818 C 193.237 172.586 192.152 177.414 195.718 179.766 C 196.949 180.974 200.987 182.446 202.777 183.326 C 209.203 185.979 212.499 191.309 205.674 195.839 C 201.861 197.685 197.972 198.767 194.414 201.184 C 187.519 205.522 181.325 211.283 174.736 216.332 C 171.822 218.443 168.059 221.036 163.892 221.219 C 157.221 221.539 149.907 217.024 148.541 210.28 C 147.889 205.092 149.28 199.972 149.974 194.959 C 150.904 189.491 151.747 184.065 152.079 178.594 C 152.748 173.682 151.132 163.049 158.578 162.858 C 163.322 163.372 162.987 169.245 164.743 172.446 C 166.393 174.955 170.386 176.7 173.676 176.883 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 879 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200px" height="200px" viewBox="0 0 200 200">
|
||||||
|
<path d="M 176.226 68.183 C 175.066 69.083 154.581 80.625 154.581 106.291 C 154.581 135.977 180.646 146.48 181.427 146.739 C 181.307 147.38 177.286 161.122 167.684 175.125 C 159.122 187.448 150.18 199.75 136.577 199.75 C 122.974 199.75 119.474 191.849 103.771 191.849 C 88.467 191.849 83.027 200.011 70.583 200.011 C 58.141 200.011 49.46 188.608 39.478 174.605 C 27.915 158.162 18.573 132.617 18.573 108.372 C 18.573 69.484 43.858 48.859 68.744 48.859 C 81.966 48.859 92.988 57.541 101.29 57.541 C 109.191 57.541 121.514 48.339 136.557 48.339 C 142.259 48.339 162.743 48.859 176.226 68.183 Z M 129.416 31.876 C 135.637 24.494 140.038 14.252 140.038 4.01 C 140.038 2.59 139.918 1.149 139.658 -0.011 C 129.536 0.369 117.493 6.73 110.232 15.152 C 104.531 21.634 99.209 31.876 99.209 42.257 C 99.209 43.818 99.47 45.379 99.59 45.879 C 100.23 45.998 101.27 46.139 102.31 46.139 C 111.393 46.139 122.815 40.057 129.416 31.876 Z" style="fill: rgb(255, 255, 255);" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42">
|
||||||
|
<path fill="#FFFFFF" fill-rule="evenodd" d="M21.47 41.88c-4.11 0-6.02-.6-9.34-3-2.1 2.7-8.75 4.81-9.04 1.2 0-2.71-.6-5-1.28-7.5C1 29.5.08 26.07.08 21.1.08 9.23 9.82.3 21.36.3c11.55 0 20.6 9.37 20.6 20.91a20.6 20.6 0 0 1-20.49 20.67Zm.17-31.32c-5.62-.29-10 3.6-10.97 9.7-.8 5.05.62 11.2 1.83 11.52.58.14 2.04-1.04 2.95-1.95a10.4 10.4 0 0 0 5.08 1.81 10.7 10.7 0 0 0 11.19-9.97 10.7 10.7 0 0 0-10.08-11.1Z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200px" height="200px">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M 13.693 88.614 C 67.369 65.304 103.102 49.815 121.044 42.299 C 172.114 20.984 182.849 17.303 189.751 17.149 C 191.284 17.149 194.658 17.456 196.958 19.295 C 198.799 20.829 199.259 22.823 199.565 24.356 C 199.873 25.889 200.18 29.111 199.873 31.565 C 197.112 60.703 185.15 131.402 179.016 163.916 C 176.408 177.717 171.347 182.317 166.441 182.778 C 155.705 183.699 147.577 175.724 137.302 168.976 C 121.044 158.395 111.997 151.8 96.202 141.371 C 77.951 129.408 89.76 122.815 100.188 112.08 C 102.948 109.319 150.031 66.378 150.95 62.545 C 151.104 62.084 151.104 60.243 150.031 59.323 C 148.956 58.403 147.423 58.71 146.197 59.016 C 144.509 59.323 118.745 76.5 68.596 110.391 C 61.235 115.453 54.64 117.906 48.659 117.754 C 42.065 117.6 29.49 114.072 19.981 111.005 C 8.479 107.325 -0.723 105.331 0.045 98.89 C 0.503 95.516 5.105 92.142 13.693 88.614 Z" style="fill: rgb(255, 255, 255);"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="0" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
@@ -1,5 +1,5 @@
|
|||||||
import { AuthContainer } from "./Auth";
|
import { AuthContainer } from "./Auth";
|
||||||
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
@@ -8,18 +8,23 @@ import { RegisterForm } from "./RegisterForm";
|
|||||||
import type { Variants, Transition } from "motion/react";
|
import type { Variants, Transition } from "motion/react";
|
||||||
import styles from "./auth.module.scss";
|
import styles from "./auth.module.scss";
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 400;
|
||||||
|
|
||||||
const slideVariants: Variants = {
|
const slideVariants: Variants = {
|
||||||
enter: (direction: number) => ({
|
enter: (direction: number) => ({
|
||||||
x: direction > 0 ? 300 : -300,
|
x: direction > 0 ? 300 : -300,
|
||||||
opacity: 0
|
opacity: 0,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
}),
|
}),
|
||||||
center: {
|
center: {
|
||||||
x: 0,
|
x: 0,
|
||||||
opacity: 1
|
opacity: 1,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
},
|
},
|
||||||
exit: (direction: number) => ({
|
exit: (direction: number) => ({
|
||||||
x: direction > 0 ? -300 : 300,
|
x: direction > 0 ? -300 : 300,
|
||||||
opacity: 0
|
opacity: 0,
|
||||||
|
y: 0 // Ensure no vertical movement
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,80 +50,55 @@ export default function AuthPage() {
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const loginFormRef = useRef<HTMLDivElement>(null);
|
const loginFormRef = useRef<HTMLDivElement>(null);
|
||||||
const registerFormRef = useRef<HTMLDivElement>(null);
|
const registerFormRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
|
const [containerHeight, setContainerHeight] = useState<number>(400);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
const currentMode = searchParams.get("mode") || "login";
|
const currentMode = searchParams.get("mode") || "login";
|
||||||
const enteringElementRef = useRef<"login" | "register" | null>(null);
|
|
||||||
const [effectActivated, setEffectActivated] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevMode.current !== currentMode) {
|
if (prevMode.current !== currentMode) {
|
||||||
setDirection(currentMode === "register" ? 1 : -1);
|
setDirection(currentMode === "register" ? 1 : -1);
|
||||||
prevMode.current = currentMode;
|
prevMode.current = currentMode;
|
||||||
enteringElementRef.current = currentMode as "login" | "register";
|
setIsTransitioning(true);
|
||||||
}
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
const measureActiveHeight = useCallback(() => {
|
// Setup ResizeObserver to watch for content changes
|
||||||
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
|
useEffect(() => {
|
||||||
if (activeComponent) {
|
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
|
||||||
const height = activeComponent.scrollHeight;
|
|
||||||
|
if (activeRef.current) {
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const height = entry.contentRect.height;
|
||||||
if (height > 0) {
|
if (height > 0) {
|
||||||
setContainerHeight(height);
|
setContainerHeight(Math.max(height, MIN_HEIGHT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [currentMode, loginFormRef, registerFormRef]);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!effectActivated) {
|
|
||||||
setEffectActivated(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always measure, but prioritize the entering element during transitions
|
|
||||||
// Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
|
|
||||||
let rafId2: number | null = null;
|
|
||||||
const rafId1 = requestAnimationFrame(() => {
|
|
||||||
rafId2 = requestAnimationFrame(() => {
|
|
||||||
measureActiveHeight();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(activeRef.current);
|
||||||
|
|
||||||
|
// Initial measurement
|
||||||
|
const initialHeight = activeRef.current.scrollHeight;
|
||||||
|
if (initialHeight > 0) {
|
||||||
|
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(rafId1);
|
resizeObserver.disconnect();
|
||||||
if (rafId2 !== null) {
|
|
||||||
cancelAnimationFrame(rafId2);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}, [currentMode]);
|
}, [currentMode]);
|
||||||
|
|
||||||
|
|
||||||
function switchMode(newMode: "login" | "register") {
|
function switchMode(newMode: "login" | "register") {
|
||||||
navigate(`/auth?mode=${newMode}`, { replace: true });
|
navigate(`/auth?mode=${newMode}`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAnimationComplete(
|
function handleAnimationComplete() {
|
||||||
currentMode: "login" | "register",
|
setIsTransitioning(false);
|
||||||
mode: "login" | "register",
|
}
|
||||||
enteringElementRef: RefObject<"login" | "register" | null>,
|
|
||||||
formRef: React.RefObject<HTMLDivElement | null>,
|
|
||||||
setContainerHeight: (height: number) => void
|
|
||||||
) {
|
|
||||||
return () => {
|
|
||||||
if (currentMode === mode && enteringElementRef.current === mode) {
|
|
||||||
enteringElementRef.current = null;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (formRef.current && currentMode === mode) {
|
|
||||||
const height = formRef.current.scrollHeight;
|
|
||||||
if (height > 0) {
|
|
||||||
setContainerHeight(height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContainer>
|
<AuthContainer>
|
||||||
@@ -127,14 +107,8 @@ export default function AuthPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
|
height: `${containerHeight}px`,
|
||||||
transition: "height 0.3s ease"
|
transition: isTransitioning ? "height 0.3s ease" : "none"
|
||||||
}}
|
|
||||||
onAnimationStart={() => {
|
|
||||||
|
|
||||||
}}
|
|
||||||
onAnimationEnd={() => {
|
|
||||||
setContainerHeight("auto");
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="sync" custom={direction}>
|
<AnimatePresence mode="sync" custom={direction}>
|
||||||
@@ -148,11 +122,8 @@ export default function AuthPage() {
|
|||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={slideTransition}
|
transition={slideTransition}
|
||||||
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
style={{
|
|
||||||
position: containerHeight === "auto" ? "relative" : "absolute"
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LoginForm onSwitchMode={() => switchMode("register")} />
|
<LoginForm onSwitchMode={() => switchMode("register")} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -166,7 +137,7 @@ export default function AuthPage() {
|
|||||||
animate="center"
|
animate="center"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={slideTransition}
|
transition={slideTransition}
|
||||||
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
|
onAnimationComplete={handleAnimationComplete}
|
||||||
className={styles.formWrapper}
|
className={styles.formWrapper}
|
||||||
>
|
>
|
||||||
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
<RegisterForm onSwitchMode={() => switchMode("login")} />
|
||||||
|
|||||||
@@ -98,6 +98,12 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
&.relative {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.authHeader {
|
.authHeader {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { MaterialButton } from "@/utils/material";
|
import { MaterialIcon } from "@/utils/material";
|
||||||
import styles from "./download-app.module.scss";
|
import styles from "./download-app.module.scss";
|
||||||
|
|
||||||
export default function DownloadAppPage() {
|
export default function DownloadAppPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.downloadAppScreen}>
|
<div className={styles.downloadAppScreen}>
|
||||||
<div>
|
<div className={styles.downloadAppCard}>
|
||||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
<h1>Скачайте приложение</h1>
|
||||||
<p>
|
<p>
|
||||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
Этот сайт не предназначен для работы на маленьких экранах.
|
||||||
вам нужно скачать приложение мессенджера.
|
Выберите вашу платформу:
|
||||||
</p>
|
</p>
|
||||||
|
<div className={styles.downloadAppButtons}>
|
||||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
<a href="/download?os=android" className={styles.downloadAppBtn}>
|
||||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
<MaterialIcon name="android" />
|
||||||
|
Android
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/download?os=ios" className={styles.downloadAppBtn}>
|
||||||
<p>
|
<MaterialIcon name="phone_iphone" />
|
||||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
iOS
|
||||||
</p>
|
|
||||||
|
|
||||||
<a href="https://t.me/denis0001-dev">
|
|
||||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<p>
|
||||||
|
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,70 @@
|
|||||||
|
@use "../../css/material" as *;
|
||||||
|
|
||||||
.downloadAppScreen {
|
.downloadAppScreen {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 100vw;
|
min-width: 100vw;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
|
background-color: $color-dark-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppCard {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: rgba($color-dark-surface-container, 0.6);
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-dark-primary;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-primary, 0.2);
|
||||||
|
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($color-dark-primary, 0.3);
|
||||||
|
border-color: $color-dark-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||||
|
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||||
|
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
|
||||||
|
import styles from "@/pages/home/download-dialog.module.scss";
|
||||||
|
|
||||||
|
interface DownloadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
os: DownloadOs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AndroidInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на Android</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Вы скачали APK-файл FromChat. Чтобы установить приложение:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>Откройте загруженный APK-файл из шторки уведомлений или файлового менеджера.</li>
|
||||||
|
<li>
|
||||||
|
Если появится запрос "Разрешить установку из неизвестных источников" — дайте
|
||||||
|
разрешение для браузера, из которого вы скачивали APK.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
|
||||||
|
нажмите "Подробнее" → "Всё равно установить" (или аналогичную кнопку).
|
||||||
|
</li>
|
||||||
|
<li>Дождитесь завершения установки и откройте FromChat из списка приложений.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IosInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на iOS</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone
|
||||||
|
или iPad, потребуется один из вариантов сторонней установки:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>TrollStore</strong>: постоянная установка приложений из IPA-файлов. Требуется поддерживаемая
|
||||||
|
версия iOS и настройка TrollStore на устройстве.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Джейлбрейк</strong>: установка через менеджер пакетов (Sileo, Cydia и т.п.) или напрямую
|
||||||
|
из файлового менеджера, если у вас уже есть джейлбрейк.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Другие сервисы сайдлоада</strong>: сторонние инструменты, которые подписывают IPA-файл
|
||||||
|
вашим сертификатом разработчика или временным сертификатом.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className={styles.text}>
|
||||||
|
К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет — именно поэтому я
|
||||||
|
бы сам iPhone не покупал 😄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstructions(os: DownloadOs): ReactNode {
|
||||||
|
if (os === "android") {
|
||||||
|
return <AndroidInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (os === "ios") {
|
||||||
|
return <IosInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) {
|
||||||
|
const osInfo = OS_CONFIG[os];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
className={styles.downloadDialog}
|
||||||
|
contentClassName={styles.downloadDialogContent}
|
||||||
|
afterChildren={
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<MaterialButton variant="filled" onClick={() => onOpenChange(false)}>
|
||||||
|
Закрыть
|
||||||
|
</MaterialButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<MaterialIcon name="download" className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h2 className={styles.title}>Спасибо за скачивание!</h2>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
FromChat для
|
||||||
|
<span className={styles.osName}>{osInfo.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderInstructions(os)}
|
||||||
|
</div>
|
||||||
|
</StyledDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { MaterialIcon } from "@/utils/material";
|
||||||
|
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-footer.module.scss";
|
||||||
|
|
||||||
|
interface HomeFooterProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
|
||||||
|
return (
|
||||||
|
<footer className={styles.homepageFooter}>
|
||||||
|
<div className={styles.footerBrand}>
|
||||||
|
<div className={styles.footerLogoRow}>
|
||||||
|
<div className={styles.footerLogo} />
|
||||||
|
<span className={styles.footerBrandName}>FromChat</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.footerCopyright}>FromChat © 2026</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerLinks}>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onScrollToDownload}
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="download" className={styles.footerLinkIcon} />
|
||||||
|
Скачать приложение
|
||||||
|
</button>
|
||||||
|
<Link to="/login" className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="language" className={styles.footerLinkIcon} />
|
||||||
|
Веб-версия
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
|
||||||
|
ПК-клиент
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_APP}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="android" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код приложения
|
||||||
|
</a>
|
||||||
|
<GitHubLink className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="code" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код веб-версии
|
||||||
|
</GitHubLink>
|
||||||
|
<a
|
||||||
|
href={GITHUB_LICENSE}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="description" className={styles.footerLinkIcon} />
|
||||||
|
Лицензия
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href="https://t.me/fromchat_ch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
|
||||||
|
/>
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
|
||||||
|
/>
|
||||||
|
MAX
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
|
import { MaterialButton, MaterialIconButton } from "@/utils/material";
|
||||||
|
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-header.module.scss";
|
||||||
|
|
||||||
|
interface HomeHeaderProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeHeader({ onScrollToDownload }: HomeHeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const { isMobile } = useDownloadAppScreen();
|
||||||
|
const isLoggedIn = user.authToken && user.currentUser;
|
||||||
|
|
||||||
|
const handleMobileDownload = () => {
|
||||||
|
onScrollToDownload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleGetStarted() {
|
||||||
|
if (isMobile) {
|
||||||
|
handleMobileDownload();
|
||||||
|
} else if (isLoggedIn) {
|
||||||
|
navigate("/chat");
|
||||||
|
} else {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBtn = (
|
||||||
|
<MaterialButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleGetStarted}
|
||||||
|
icon={
|
||||||
|
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
|
||||||
|
}
|
||||||
|
className={styles.headerDownloadButton}
|
||||||
|
>
|
||||||
|
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
|
||||||
|
</MaterialButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.homepageHeader} data-home-header>
|
||||||
|
<div className={styles.headerInner}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<div className={styles.logoIcon} />
|
||||||
|
<h1>FromChat</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerCenterLinks}>
|
||||||
|
<GitHubLink>
|
||||||
|
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
|
||||||
|
</GitHubLink>
|
||||||
|
<SupportLink>
|
||||||
|
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
|
||||||
|
</SupportLink>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerButton}>
|
||||||
|
{openBtn}
|
||||||
|
{isMobile ? (
|
||||||
|
<MaterialIconButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleMobileDownload}
|
||||||
|
icon="download"
|
||||||
|
className={styles.headerSmallButton}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,228 +1,236 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useUserStore } from "@/state/user";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import styles from "./home.module.scss";
|
import styles from "@/pages/home/home.module.scss";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||||
|
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
|
||||||
|
import dmScreenshot from "@/images/screenshots/dm.png";
|
||||||
|
import windowsIcon from "@/images/windows.svg";
|
||||||
|
import linuxIcon from "@/images/linux.svg";
|
||||||
|
import macIcon from "@/images/mac.svg";
|
||||||
|
import { HomeHeader } from "@/pages/home/HomeHeader";
|
||||||
|
import { HomeFooter } from "@/pages/home/HomeFooter";
|
||||||
|
import { SplitButton } from "@/core/components/SplitButton";
|
||||||
|
import { DownloadDialog } from "@/pages/home/DownloadDialog";
|
||||||
|
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
|
||||||
|
|
||||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
interface FeatureSectionProps {
|
||||||
return (
|
title: ReactNode;
|
||||||
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
|
children: ReactNode;
|
||||||
);
|
screenshot: string;
|
||||||
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SupportLink({ children }: { children: React.ReactNode }) {
|
function FeatureSection({
|
||||||
return (
|
title,
|
||||||
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
|
children,
|
||||||
|
screenshot,
|
||||||
|
right = false,
|
||||||
|
}: FeatureSectionProps) {
|
||||||
|
const featureText = (
|
||||||
|
<div className={styles.featureText}>
|
||||||
|
<div className={styles.featureTitle}>{title}</div>
|
||||||
|
<div className={styles.featureDesc}>{children}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const featureScreenshot = (
|
||||||
|
<div className={styles.featureScreenshotOuter}>
|
||||||
|
<div className={styles.featureScreenshotGlow} />
|
||||||
|
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.featureContainer}`}>
|
||||||
|
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useUserStore();
|
|
||||||
const { isMobile } = useDownloadAppScreen();
|
const { isMobile } = useDownloadAppScreen();
|
||||||
const isLoggedIn = user.authToken && user.currentUser;
|
|
||||||
|
|
||||||
function handleGetStarted() {
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
if (isMobile) {
|
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
|
||||||
navigate("/download-app");
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
} else if (isLoggedIn) {
|
const downloadSectionRef = useRef<HTMLElement>(null);
|
||||||
navigate("/chat");
|
|
||||||
} else {
|
const scrollToDownload = () => {
|
||||||
navigate("/login");
|
const section = downloadSectionRef.current;
|
||||||
}
|
const header = document.querySelector<HTMLElement>("[data-home-header]");
|
||||||
|
if (!section) return;
|
||||||
|
const headerHeight = header?.getBoundingClientRect().height ?? 0;
|
||||||
|
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
|
||||||
|
window.scrollTo({ top: targetY, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = (os: DownloadOs): boolean => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openBtn = (
|
setDialogOs(os);
|
||||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
setDialogOpen(true);
|
||||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
|
||||||
</MaterialButton>
|
const link = document.createElement("a");
|
||||||
);
|
link.href = `/api/download/${os}`;
|
||||||
|
link.download = "";
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
|
||||||
|
const detectedOs = detectOs();
|
||||||
|
if (os === detectedOs) return "filled";
|
||||||
|
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
|
||||||
|
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
|
||||||
|
return "outlined";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homepage}>
|
<div className={styles.homepage}>
|
||||||
<header className={styles.homepageHeader}>
|
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<div className={styles.logo}>
|
|
||||||
<h1>FromChat</h1>
|
|
||||||
<span className={styles.tagline}>100% открытый мессенджер</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.headerNav}>
|
|
||||||
<GitHubLink>
|
|
||||||
<MaterialButton variant="text">GitHub</MaterialButton>
|
|
||||||
</GitHubLink>
|
|
||||||
<SupportLink>
|
|
||||||
<MaterialButton variant="text">Поддержка</MaterialButton>
|
|
||||||
</SupportLink>
|
|
||||||
|
|
||||||
{openBtn}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className={styles.hero}>
|
<section className={styles.title}>
|
||||||
<div className={styles.container}>
|
<div className={styles.titleLogoWrapper}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.titleLogo} />
|
||||||
<h2 className={styles.heroTitle}>
|
</div>
|
||||||
Безопасный мессенджер с открытым исходным кодом
|
<div className={styles.titleContent}>FromChat</div>
|
||||||
</h2>
|
<div className={styles.titleDesc}>
|
||||||
<p className={styles.heroDescription}>
|
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
|
||||||
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
|
</div>
|
||||||
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
|
<div className={styles.titleButtons}>
|
||||||
</p>
|
{isMobile ? null : (
|
||||||
<div className={styles.heroActions}>
|
|
||||||
{openBtn}
|
|
||||||
{!isMobile && (
|
|
||||||
<MaterialButton
|
<MaterialButton
|
||||||
variant="outlined"
|
variant="filled"
|
||||||
onClick={() => navigate("/register")}>
|
onClick={() => navigate("/auth?mode=login")}
|
||||||
Зарегистрироваться
|
icon="devices"
|
||||||
|
>
|
||||||
|
Открыть веб-версию
|
||||||
</MaterialButton>
|
</MaterialButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
<SplitButton
|
||||||
</div>
|
variant={isMobile ? "filled" : "tonal"}
|
||||||
<div className={styles.heroVisual}>
|
text="Скачать приложение"
|
||||||
<div className={styles.chatPreview}>
|
icon="download"
|
||||||
<div className={styles.chatWindow}>
|
onPrimaryClick={() => triggerDownload(detectOs())}
|
||||||
<div className={styles.chatHeader}>
|
menuOpen={menuOpen}
|
||||||
<div className={styles.chatTitle}>Общий чат</div>
|
onMenuOpen={setMenuOpen}
|
||||||
<div className={styles.onlineIndicator}>●</div>
|
menu={(
|
||||||
</div>
|
<MaterialList>
|
||||||
<div className={styles.chatMessages}>
|
{ALL_OS.map((os) => (
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
<MaterialListItem
|
||||||
<div className={styles.messageAvatar}>А</div>
|
key={os}
|
||||||
<div className={styles.messageContent}>
|
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
|
||||||
<div className={styles.messageText}>Привет! Как дела?</div>
|
headline={OS_CONFIG[os].label}
|
||||||
<div className={styles.messageTime}>14:30</div>
|
rounded
|
||||||
</div>
|
onClick={() => {
|
||||||
</div>
|
if (triggerDownload(os)) setMenuOpen(false);
|
||||||
<div className={`${styles.message} ${styles.sent}`}>
|
}}
|
||||||
<div className={styles.messageContent}>
|
>
|
||||||
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
|
{["windows", "linux", "macos"].includes(os) && (
|
||||||
<div className={styles.messageTime}>14:32</div>
|
<span
|
||||||
</div>
|
slot="icon"
|
||||||
</div>
|
className={styles.menuCustomIcon}
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
style={{
|
||||||
<div className={styles.messageAvatar}>Б</div>
|
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
<div className={styles.messageContent}>
|
} as React.CSSProperties}
|
||||||
<div className={styles.messageText}>Отправляю файл 📎</div>
|
/>
|
||||||
<div className={styles.messageTime}>14:35</div>
|
)}
|
||||||
</div>
|
</MaterialListItem>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</MaterialList>
|
||||||
</div>
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.features}>
|
<section className={styles.features}>
|
||||||
<div className={styles.container}>
|
<FeatureSection
|
||||||
<h3 className={styles.sectionTitle}>Возможности</h3>
|
title={<>Общий чат</>}
|
||||||
<div className={styles.featuresGrid}>
|
screenshot={generalChatScreenshot}
|
||||||
<div className={styles.featureCard}>
|
right
|
||||||
<div className={styles.featureIcon}>
|
>
|
||||||
<MaterialIcon name="security" />
|
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
|
||||||
</div>
|
</FeatureSection>
|
||||||
<h4>End-to-End Шифрование</h4>
|
<FeatureSection
|
||||||
<p>
|
title={<>Личные сообщения</>}
|
||||||
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
|
screenshot={dmScreenshot}>
|
||||||
Только вы и получатель можете прочитать сообщения.
|
Общайтесь с одним человеком в личной переписке.
|
||||||
</p>
|
</FeatureSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="code" />
|
|
||||||
</div>
|
|
||||||
<h4>100% открытый код</h4>
|
|
||||||
<p>
|
|
||||||
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
|
|
||||||
внести изменения или развернуть свой сервер.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="attach_file" />
|
|
||||||
</div>
|
|
||||||
<h4>Обмен Файлами</h4>
|
|
||||||
<p>
|
|
||||||
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
|
|
||||||
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="notifications" />
|
|
||||||
</div>
|
|
||||||
<h4>Уведомления</h4>
|
|
||||||
<p>
|
|
||||||
Получайте push-уведомления в браузере и настольном приложении.
|
|
||||||
Никогда не пропустите важное сообщение.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="edit" />
|
|
||||||
</div>
|
|
||||||
<h4>Редактирование</h4>
|
|
||||||
<p>
|
|
||||||
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
|
|
||||||
для лучшего контекста общения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="computer" />
|
|
||||||
</div>
|
|
||||||
<h4>Кроссплатформенность</h4>
|
|
||||||
<p>
|
|
||||||
Работает в браузере и как настольное приложение для Windows,
|
|
||||||
macOS и Linux. Единый интерфейс везде.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.download}>
|
<section ref={downloadSectionRef} className={styles.download}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.downloadContent}>
|
<div className={styles.downloadContent}>
|
||||||
<h3>Скачайте приложение</h3>
|
<h3>Скачайте приложение</h3>
|
||||||
<p>
|
<p>
|
||||||
Для лучшего опыта используйте настольное приложение с поддержкой
|
Настольное приложение с уведомлениями и автономной работой
|
||||||
уведомлений и автономной работы.
|
или мобильное приложение для Android и iOS.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.downloadButtons}>
|
<table className={styles.downloadTable}>
|
||||||
{!isMobile ? (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<a
|
<th>Платформа</th>
|
||||||
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
|
<th>Описание</th>
|
||||||
target="_blank"
|
<th />
|
||||||
rel="noopener noreferrer"
|
</tr>
|
||||||
>
|
</thead>
|
||||||
<MaterialButton variant="filled">
|
<tbody>
|
||||||
<MaterialIcon name="download" slot="icon" />
|
{ALL_OS.map((os) => (
|
||||||
Скачать для ПК
|
<tr key={os}>
|
||||||
</MaterialButton>
|
<td className={styles.downloadTableOs}>
|
||||||
</a>
|
<span className={styles.downloadTableOsContent}>
|
||||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
{["windows", "linux", "macos"].includes(os) ? (
|
||||||
<MaterialIcon name="language" slot="icon" />
|
<span
|
||||||
Веб-версия
|
className={styles.tableOsIcon}
|
||||||
</MaterialButton>
|
style={{
|
||||||
</>
|
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
|
} as React.CSSProperties}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
<MaterialIcon
|
||||||
Скачать приложение
|
name={OS_CONFIG[os].icon}
|
||||||
</MaterialButton>
|
className={styles.downloadTableIcon}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
<span>{OS_CONFIG[os].label}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableDesc}>
|
||||||
|
<span className={styles.downloadTableDescInner}>
|
||||||
|
{OS_CONFIG[os].description}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableAction}>
|
||||||
|
<span className={styles.downloadTableActionInner}>
|
||||||
|
<MaterialButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButton}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</MaterialButton>
|
||||||
|
<MaterialIconButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButtonIcon}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
title={`Скачать ${OS_CONFIG[os].label}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -232,7 +240,8 @@ export default function HomePage() {
|
|||||||
<div className={styles.ctaContent}>
|
<div className={styles.ctaContent}>
|
||||||
<h3>Готовы начать общение?</h3>
|
<h3>Готовы начать общение?</h3>
|
||||||
<p>
|
<p>
|
||||||
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
|
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
|
||||||
|
или звоните — всё бесплатно и с открытым кодом.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.ctaActions}>
|
<div className={styles.ctaActions}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -259,24 +268,8 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className={styles.homepageFooter}>
|
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
|
||||||
<div className={styles.container}>
|
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.footerContent}>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Ссылки</h4>
|
|
||||||
<GitHubLink>GitHub</GitHubLink>
|
|
||||||
<SupportLink>Поддержка</SupportLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Лицензия</h4>
|
|
||||||
<p>GPL-3.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerBottom}>
|
|
||||||
<p>© 2025 FromChat. Сделано программистом denis0001-dev с ❤️ для свободы общения.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
|
||||||
|
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
|
||||||
|
$glow-purple: rgba(147, 51, 234, 0.5);
|
||||||
|
|
||||||
|
@mixin gradient-text {
|
||||||
|
background: $gradient-rainbow;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px $glow-purple;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "@/css/colors" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
.downloadDialog {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 20px;
|
||||||
|
border-top: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||||
|
background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageFooter {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: $color-dark-surface;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
|
||||||
|
@media (max-width: 635px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrandName {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerCopyright {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinks {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLink {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.2s ease, transform 0.15s ease;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:is(button) {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvg {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgTelegram {
|
||||||
|
mask-image: url('@/images/telegram.svg');
|
||||||
|
-webkit-mask-image: url('@/images/telegram.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgMax {
|
||||||
|
mask-image: url('@/images/max.svg');
|
||||||
|
-webkit-mask-image: url('@/images/max.svg');
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerInner {
|
||||||
|
max-width: 960px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-surface, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCenterLinks {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
@media (max-width: 730px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerSmallButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
.headerSmallButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
|
||||||
|
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
|
||||||
|
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
|
||||||
|
|
||||||
|
export function GitHubLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://t.me/denis0001-dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GITHUB_WEB, GITHUB_APP };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type DownloadOs = "windows" | "linux" | "macos" | "android" | "ios";
|
||||||
|
|
||||||
|
export const ALL_OS: DownloadOs[] = [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"macos",
|
||||||
|
"android",
|
||||||
|
"ios",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface OsInfo {
|
||||||
|
id: DownloadOs;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OS_CONFIG: Record<DownloadOs, OsInfo> = {
|
||||||
|
windows: { id: "windows", label: "Windows", description: "ПК", icon: "computer" },
|
||||||
|
linux: { id: "linux", label: "Linux", description: "ПК", icon: "computer" },
|
||||||
|
macos: { id: "macos", label: "macOS", description: "Apple", icon: "computer" },
|
||||||
|
android: { id: "android", label: "Android", description: "APK", icon: "android" },
|
||||||
|
ios: { id: "ios", label: "iOS", description: "iPhone, iPad", icon: "phone_iphone" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectOs(): DownloadOs {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = (navigator.userAgent || navigator.platform || "").toLowerCase();
|
||||||
|
const platform = (navigator as any).userAgentData?.platform?.toLowerCase?.() ?? "";
|
||||||
|
const haystack = `${ua} ${platform}`;
|
||||||
|
|
||||||
|
if (haystack.includes("android")) {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("iphone") || haystack.includes("ipad") || haystack.includes("ipod")) {
|
||||||
|
return "ios";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("win")) {
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("mac")) {
|
||||||
|
return "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("linux")) {
|
||||||
|
return "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
@@ -17,18 +17,21 @@ import 'mdui/components/button';
|
|||||||
import 'mdui/components/text-field';
|
import 'mdui/components/text-field';
|
||||||
import 'mdui/components/button-icon';
|
import 'mdui/components/button-icon';
|
||||||
import 'mdui/components/switch';
|
import 'mdui/components/switch';
|
||||||
|
import 'mdui/components/ripple';
|
||||||
import 'mdui/components/chip';
|
import 'mdui/components/chip';
|
||||||
import 'mdui/components/badge';
|
import 'mdui/components/badge';
|
||||||
import "mdui/mdui.css";
|
import "mdui/mdui.css";
|
||||||
import 'mdui/components/circular-progress';
|
import 'mdui/components/circular-progress';
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
||||||
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
|
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react';
|
||||||
import type { TextField } from 'mdui/components/text-field';
|
import type { TextField } from 'mdui/components/text-field';
|
||||||
import type { Switch } from 'mdui/components/switch';
|
import type { Switch } from 'mdui/components/switch';
|
||||||
import type { Override } from '@/core/types';
|
import type { Override } from '@/core/types';
|
||||||
import type { Button } from 'mdui/components/button';
|
import type { Button } from 'mdui/components/button';
|
||||||
import type { ButtonIcon } from 'mdui/components/button-icon';
|
import type { ButtonIcon } from 'mdui/components/button-icon';
|
||||||
|
import type { Ripple } from 'mdui/components/ripple';
|
||||||
import type { Icon } from 'mdui/components/icon';
|
import type { Icon } from 'mdui/components/icon';
|
||||||
import type { Fab } from 'mdui/components/fab';
|
import type { Fab } from 'mdui/components/fab';
|
||||||
import type { Tabs } from 'mdui/components/tabs';
|
import type { Tabs } from 'mdui/components/tabs';
|
||||||
@@ -58,6 +61,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
|
|||||||
export type MDUIButton = Override<HTMLElement, Button>;
|
export type MDUIButton = Override<HTMLElement, Button>;
|
||||||
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
||||||
export type MDUIIcon = Override<HTMLElement, Icon>;
|
export type MDUIIcon = Override<HTMLElement, Icon>;
|
||||||
|
export type MDUIRipple = Override<HTMLElement, Ripple>;
|
||||||
export type MDUIFab = Override<HTMLElement, Fab>;
|
export type MDUIFab = Override<HTMLElement, Fab>;
|
||||||
export type MDUITabs = Override<HTMLElement, Tabs>;
|
export type MDUITabs = Override<HTMLElement, Tabs>;
|
||||||
export type MDUITab = Override<HTMLElement, Tab>;
|
export type MDUITab = Override<HTMLElement, Tab>;
|
||||||
@@ -141,3 +145,68 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
|
|||||||
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
||||||
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MaterialRippleProps = NoChildren<BasePropCustomization<"mdui-ripple", MDUIRipple>>;
|
||||||
|
export function MaterialRipple(props: MaterialRippleProps) {
|
||||||
|
return <mdui-ripple {...props as ComponentProps<"mdui-ripple">} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns pointer handlers that forward press and hover events to an mdui-ripple element.
|
||||||
|
* Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled);
|
||||||
|
* <button {...rippleHandlers}>
|
||||||
|
* <MaterialRipple ref={rippleRef} />
|
||||||
|
* ...
|
||||||
|
* </button>
|
||||||
|
*/
|
||||||
|
export function useRippleHandlers(
|
||||||
|
disabled = false
|
||||||
|
): {
|
||||||
|
rippleRef: RefObject<MDUIRipple | null>;
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerEnter: (e: React.PointerEvent) => void;
|
||||||
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
|
} {
|
||||||
|
const rippleRef = useRef<MDUIRipple | null>(null);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.button !== 0) return;
|
||||||
|
const ripple = rippleRef.current;
|
||||||
|
if (!ripple?.startPress) return;
|
||||||
|
ripple.startPress(e.nativeEvent);
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const endPress = () => {
|
||||||
|
ripple.endPress?.();
|
||||||
|
btn.removeEventListener("pointerup", endPress);
|
||||||
|
btn.removeEventListener("pointercancel", endPress);
|
||||||
|
btn.removeEventListener("pointerleave", endPress);
|
||||||
|
};
|
||||||
|
btn.addEventListener("pointerup", endPress);
|
||||||
|
btn.addEventListener("pointercancel", endPress);
|
||||||
|
btn.addEventListener("pointerleave", endPress);
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerEnter = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.startHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerLeave = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.endHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave };
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ declare global {
|
|||||||
interface SyntheticEvent<T = Element, E = Event> {
|
interface SyntheticEvent<T = Element, E = Event> {
|
||||||
target: EventTarget & T;
|
target: EventTarget & T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Augment DOM event listeners to provide typed target for ALL elements
|
// Augment DOM event listeners to provide typed target for ALL elements
|
||||||
|
|||||||
@@ -314,8 +314,6 @@ step "Detecting services"
|
|||||||
cd "$DEPLOYMENT_DIR"
|
cd "$DEPLOYMENT_DIR"
|
||||||
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
|
SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null)
|
||||||
|
|
||||||
# Note: Using multi-stage Dockerfile - no separate base image build needed
|
|
||||||
|
|
||||||
if [ -z "$SERVICES" ]; then
|
if [ -z "$SERVICES" ]; then
|
||||||
error "No services found in docker-compose.yml"
|
error "No services found in docker-compose.yml"
|
||||||
fi
|
fi
|
||||||
@@ -343,10 +341,6 @@ for SERVICE in $SERVICES; do
|
|||||||
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \
|
||||||
tr -d '"' | tr -d "'" | xargs)
|
tr -d '"' | tr -d "'" | xargs)
|
||||||
|
|
||||||
TARGET=$(echo "$BUILD_OUTPUT" | grep "target:" | \
|
|
||||||
sed 's/.*target:[[:space:]]*\(.*\)/\1/' | \
|
|
||||||
tr -d '"' | tr -d "'" | xargs)
|
|
||||||
|
|
||||||
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
|
CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \
|
||||||
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \
|
||||||
tr -d '"' | tr -d "'" | xargs)
|
tr -d '"' | tr -d "'" | xargs)
|
||||||
@@ -383,18 +377,12 @@ for SERVICE in $SERVICES; do
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Safety check: ensure no sqlite in DATABASE_URL for Docker services
|
if docker buildx build \
|
||||||
if grep -q "DATABASE_URL.*sqlite" "$DEPLOYMENT_DIR/docker-compose.yml" 2>/dev/null; then
|
--platform "$PLATFORM" \
|
||||||
error "Found sqlite DATABASE_URL in docker-compose.yml - SQLite not allowed in Docker"
|
--file "$DOCKERFILE" \
|
||||||
exit 1
|
--tag "$IMAGE_TAG" \
|
||||||
fi
|
--load \
|
||||||
|
"$BUILD_CONTEXT"; then
|
||||||
BUILD_ARGS="--platform \"$PLATFORM\" --file \"$DOCKERFILE\" --tag \"$IMAGE_TAG\" --load"
|
|
||||||
if [ -n "$TARGET" ]; then
|
|
||||||
BUILD_ARGS="$BUILD_ARGS --target \"$TARGET\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
if docker buildx build $BUILD_ARGS "$BUILD_CONTEXT"; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}"
|
echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}"
|
||||||
BUILT_IMAGES+=("$IMAGE_TAG")
|
BUILT_IMAGES+=("$IMAGE_TAG")
|
||||||
echo ""
|
echo ""
|
||||||
@@ -419,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
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ echo > deployment/.env
|
|||||||
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
./.venv/bin/python3 backend/generate_vapid_keys.py >> deployment/.env
|
||||||
|
|
||||||
cat >> deployment/.env <<EOF
|
cat >> deployment/.env <<EOF
|
||||||
VAPID_SUBJECT=mailto:support@fromchat.ru
|
|
||||||
JWT_SECRET="$(openssl rand -base64 32)"
|
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>
|
FIREBASE_CERT=<set>
|
||||||
DB_PASSWORD=development
|
RELEASES_TOKEN=<set>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||