refactor(geoip): reconcile geoip system (#31)
This commit is contained in:
2
.github/workflows/deploy-api.yaml
vendored
2
.github/workflows/deploy-api.yaml
vendored
@@ -231,8 +231,6 @@ jobs:
|
||||
CLAMAV_HOST=clamav
|
||||
CLAMAV_PORT=3310
|
||||
|
||||
GEOIP_HOST=fluxer-geoip_app:8080
|
||||
GEOIP_PROVIDER=maxmind
|
||||
MAXMIND_DB_PATH=/data/GeoLite2-City.mmdb
|
||||
|
||||
VAPID_PUBLIC_KEY=BEIwQxIwfj6m90tLYAR0AU_GJWU4kw8J_zJcHQG55NCUWSyRy-dzMOgvxk8yEDwdVyJZa6xUL4fmwngijq8T2pY
|
||||
|
||||
124
.github/workflows/deploy-geoip.yaml
vendored
124
.github/workflows/deploy-geoip.yaml
vendored
@@ -1,124 +0,0 @@
|
||||
name: deploy geoip
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- fluxer_geoip/**
|
||||
- .github/workflows/deploy-geoip.yaml
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: deploy-fluxer-geoip
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy geoip
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Record deploy commit
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha=$(git rev-parse HEAD)
|
||||
echo "Deploying commit ${sha}"
|
||||
printf 'DEPLOY_SHA=%s\n' "$sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: fluxer_geoip
|
||||
file: fluxer_geoip/Dockerfile
|
||||
tags: fluxer-geoip:${{ env.DEPLOY_SHA }}
|
||||
load: true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=deploy-fluxer-geoip
|
||||
cache-to: type=gha,mode=max,scope=deploy-fluxer-geoip
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
- name: Install docker-pussh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.docker/cli-plugins
|
||||
curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/v0.3.1/docker-pussh \
|
||||
-o ~/.docker/cli-plugins/docker-pussh
|
||||
chmod +x ~/.docker/cli-plugins/docker-pussh
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Push image and deploy
|
||||
env:
|
||||
IMAGE_TAG: fluxer-geoip:${{ env.DEPLOY_SHA }}
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pussh "${IMAGE_TAG}" "${SERVER}"
|
||||
|
||||
ssh "${SERVER}" "IMAGE_TAG=${IMAGE_TAG} bash" << 'EOF'
|
||||
set -euo pipefail
|
||||
|
||||
sudo mkdir -p /opt/fluxer-geoip
|
||||
sudo chown -R "${USER}:${USER}" /opt/fluxer-geoip
|
||||
cd /opt/fluxer-geoip
|
||||
|
||||
cat > compose.yaml << 'COMPOSEEOF'
|
||||
services:
|
||||
app:
|
||||
image: ${IMAGE_TAG}
|
||||
volumes:
|
||||
- /etc/fluxer/ipinfo_lite.mmdb:/data/ipinfo_lite.mmdb:ro
|
||||
deploy:
|
||||
replicas: 3
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
order: start-first
|
||||
rollback_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
environment:
|
||||
- FLUXER_GEOIP_PORT=8080
|
||||
- GEOIP_DB_PATH=/data/ipinfo_lite.mmdb
|
||||
- GEOIP_CACHE_TTL=10m
|
||||
- GEOIP_CACHE_SIZE=20000
|
||||
networks:
|
||||
- fluxer-shared
|
||||
|
||||
networks:
|
||||
fluxer-shared:
|
||||
external: true
|
||||
COMPOSEEOF
|
||||
|
||||
docker stack deploy --with-registry-auth --detach=false --resolve-image never -c compose.yaml fluxer-geoip
|
||||
EOF
|
||||
1
.github/workflows/deploy-marketing.yaml
vendored
1
.github/workflows/deploy-marketing.yaml
vendored
@@ -160,7 +160,6 @@ jobs:
|
||||
- FLUXER_MARKETING_ENDPOINT=${MARKETING_ENDPOINT}
|
||||
- FLUXER_MARKETING_PORT=8080
|
||||
- FLUXER_PATH_MARKETING=/
|
||||
- GEOIP_HOST=fluxer-geoip_app:8080
|
||||
- RELEASE_CHANNEL=${RELEASE_CHANNEL}
|
||||
- FLUXER_METRICS_HOST=fluxer-metrics_app:8080
|
||||
deploy:
|
||||
|
||||
81
.github/workflows/update-geoip-db.yaml
vendored
81
.github/workflows/update-geoip-db.yaml
vendored
@@ -1,81 +0,0 @@
|
||||
name: update geoip-db
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: update-geoip-db
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
refresh-db:
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up SSH agent
|
||||
uses: webfactory/ssh-agent@v0.9.1
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY_SERVER }}
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H ${{ secrets.SERVER_IP }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Refresh MMDB on server & roll restart
|
||||
env:
|
||||
SERVER: ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }}
|
||||
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
ssh "${SERVER}" bash << EOSSH
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y curl
|
||||
fi
|
||||
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
sudo apt-get update -y
|
||||
sudo apt-get install -y golang-go
|
||||
fi
|
||||
|
||||
export PATH="\$PATH:\$(go env GOPATH)/bin"
|
||||
if ! command -v mmdbverify >/dev/null 2>&1; then
|
||||
GOBIN="\$(go env GOPATH)/bin" go install github.com/maxmind/mmdbverify@latest
|
||||
fi
|
||||
|
||||
TMPDIR="\$(mktemp -d)"
|
||||
trap 'rm -rf "\$TMPDIR"' EXIT
|
||||
|
||||
DEST_DIR="/etc/fluxer"
|
||||
DEST_DB="\${DEST_DIR}/ipinfo_lite.mmdb"
|
||||
|
||||
mkdir -p "\$DEST_DIR"
|
||||
|
||||
curl -fsSL -o "\$TMPDIR/ipinfo_lite.mmdb" \
|
||||
"https://ipinfo.io/data/ipinfo_lite.mmdb?token=${IPINFO_TOKEN}"
|
||||
|
||||
[ -s "\$TMPDIR/ipinfo_lite.mmdb" ]
|
||||
|
||||
mmdbverify -file "\$TMPDIR/ipinfo_lite.mmdb"
|
||||
|
||||
install -m 0644 "\$TMPDIR/ipinfo_lite.mmdb" "\$DEST_DB.tmp"
|
||||
mv -f "\$DEST_DB.tmp" "\$DEST_DB"
|
||||
|
||||
docker service update --force fluxer-geoip_app
|
||||
EOSSH
|
||||
@@ -18,8 +18,6 @@ FLUXER_GATEWAY_RPC_PORT=8081
|
||||
FLUXER_MEDIA_PROXY_PORT=8080
|
||||
FLUXER_ADMIN_PORT=8080
|
||||
FLUXER_MARKETING_PORT=8080
|
||||
FLUXER_GEOIP_PORT=8080
|
||||
GEOIP_HOST=geoip:8080
|
||||
|
||||
FLUXER_PATH_GATEWAY=/gateway
|
||||
FLUXER_PATH_ADMIN=/admin
|
||||
@@ -152,7 +150,6 @@ CLAMAV_PORT=3310
|
||||
|
||||
TENOR_API_KEY=
|
||||
YOUTUBE_API_KEY=
|
||||
IPINFO_TOKEN=
|
||||
|
||||
SECRET_KEY_BASE=
|
||||
GATEWAY_RPC_SECRET=
|
||||
|
||||
@@ -28,13 +28,6 @@
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@geoip path /geoip/*
|
||||
handle @geoip {
|
||||
handle_path /geoip/* {
|
||||
reverse_proxy geoip:8080
|
||||
}
|
||||
}
|
||||
|
||||
@marketing path /marketing /marketing/*
|
||||
handle @marketing {
|
||||
uri strip_prefix /marketing
|
||||
|
||||
@@ -145,19 +145,6 @@ services:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
geoip:
|
||||
image: golang:1.25.5
|
||||
working_dir: /workspace
|
||||
command: bash -c "mkdir -p /data && if [ ! -f /data/ipinfo_lite.mmdb ] && [ -n \"$$IPINFO_TOKEN\" ]; then echo 'Downloading GeoIP database...'; curl -fsSL -o /data/ipinfo_lite.mmdb \"https://ipinfo.io/data/ipinfo_lite.mmdb?token=$$IPINFO_TOKEN\" && echo 'GeoIP database downloaded'; fi && go run ."
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ../fluxer_geoip:/workspace
|
||||
- ./geoip_data:/data
|
||||
networks:
|
||||
- fluxer-shared
|
||||
restart: on-failure
|
||||
|
||||
gateway:
|
||||
image: erlang:28-slim
|
||||
working_dir: /workspace
|
||||
|
||||
34
dev/main.go
34
dev/main.go
@@ -1,34 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"fluxer.dev/dev/pkg/commands"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := commands.NewRootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"fluxer.dev/dev/pkg/integrations"
|
||||
"fluxer.dev/dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultComposeFile = "dev/compose.yaml"
|
||||
defaultEnvFile = "dev/.env"
|
||||
)
|
||||
|
||||
// NewRootCmd creates the root command
|
||||
func NewRootCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "devctl",
|
||||
Short: "Fluxer development control tool",
|
||||
Long: "Docker Compose wrapper and development utilities for Fluxer.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
NewUpCmd(),
|
||||
NewDownCmd(),
|
||||
NewRestartCmd(),
|
||||
NewLogsCmd(),
|
||||
NewPsCmd(),
|
||||
NewExecCmd(),
|
||||
NewShellCmd(),
|
||||
|
||||
NewLivekitSyncCmd(),
|
||||
NewGeoIPDownloadCmd(),
|
||||
NewEnsureNetworkCmd(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewUpCmd starts services
|
||||
func NewUpCmd() *cobra.Command {
|
||||
var detach bool
|
||||
var build bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "up [services...]",
|
||||
Short: "Start services",
|
||||
Long: "Start all or specific services using docker compose",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "up"}
|
||||
if detach {
|
||||
args = append(args, "-d")
|
||||
}
|
||||
if build {
|
||||
args = append(args, "--build")
|
||||
}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&detach, "detach", "d", true, "Run in background")
|
||||
cmd.Flags().BoolVar(&build, "build", false, "Build images before starting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewDownCmd stops and removes containers
|
||||
func NewDownCmd() *cobra.Command {
|
||||
var volumes bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "down",
|
||||
Short: "Stop and remove containers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dcArgs := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "down"}
|
||||
if volumes {
|
||||
dcArgs = append(dcArgs, "-v")
|
||||
}
|
||||
return runDockerCompose(dcArgs...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&volumes, "volumes", "v", false, "Remove volumes")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewRestartCmd restarts services
|
||||
func NewRestartCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "restart [services...]",
|
||||
Short: "Restart services",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "restart"}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewLogsCmd shows service logs
|
||||
func NewLogsCmd() *cobra.Command {
|
||||
var follow bool
|
||||
var tail string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "logs [services...]",
|
||||
Short: "Show service logs",
|
||||
RunE: func(cmd *cobra.Command, services []string) error {
|
||||
args := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "logs"}
|
||||
if follow {
|
||||
args = append(args, "-f")
|
||||
}
|
||||
if tail != "" {
|
||||
args = append(args, "--tail", tail)
|
||||
}
|
||||
args = append(args, services...)
|
||||
return runDockerCompose(args...)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&follow, "follow", "f", true, "Follow log output")
|
||||
cmd.Flags().StringVarP(&tail, "tail", "n", "100", "Number of lines to show from the end")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewPsCmd lists containers
|
||||
func NewPsCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ps",
|
||||
Short: "List containers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runDockerCompose("--env-file", defaultEnvFile, "-f", defaultComposeFile, "ps")
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewExecCmd executes a command in a running container
|
||||
func NewExecCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exec SERVICE COMMAND...",
|
||||
Short: "Execute a command in a running container",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dcArgs := []string{"--env-file", defaultEnvFile, "-f", defaultComposeFile, "exec"}
|
||||
dcArgs = append(dcArgs, args...)
|
||||
return runDockerCompose(dcArgs...)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewShellCmd opens a shell in a container
|
||||
func NewShellCmd() *cobra.Command {
|
||||
var shell string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "sh SERVICE",
|
||||
Short: "Open a shell in a container",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
service := args[0]
|
||||
return runDockerCompose("--env-file", defaultEnvFile, "-f", defaultComposeFile, "exec", service, shell)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&shell, "shell", "sh", "Shell to use (sh, bash, etc.)")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewLivekitSyncCmd syncs LiveKit configuration
|
||||
func NewLivekitSyncCmd() *cobra.Command {
|
||||
var envPath string
|
||||
var outputPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "livekit-sync",
|
||||
Short: "Generate LiveKit configuration from environment variables",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env, err := utils.ParseEnvFile(envPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read env file: %w", err)
|
||||
}
|
||||
|
||||
written, err := integrations.WriteLivekitFileFromEnv(outputPath, env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !written {
|
||||
fmt.Println("⚠️ Voice/LiveKit is disabled - no config generated")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("✅ LiveKit config written to %s\n", outputPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&envPath, "env", "e", defaultEnvFile, "Environment file path")
|
||||
cmd.Flags().StringVarP(&outputPath, "output", "o", "dev/livekit.yaml", "Output path")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewGeoIPDownloadCmd downloads GeoIP database
|
||||
func NewGeoIPDownloadCmd() *cobra.Command {
|
||||
var token string
|
||||
var envPath string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "geoip-download",
|
||||
Short: "Download GeoIP database from IPInfo",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return integrations.DownloadGeoIP(token, envPath)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&token, "token", "", "IPInfo API token")
|
||||
cmd.Flags().StringVarP(&envPath, "env", "e", defaultEnvFile, "Env file to read token from")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewEnsureNetworkCmd ensures the Docker network exists
|
||||
func NewEnsureNetworkCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "ensure-network",
|
||||
Short: "Ensure the fluxer-shared Docker network exists",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return ensureNetwork()
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runDockerCompose runs a docker compose command
|
||||
func runDockerCompose(args ...string) error {
|
||||
cmd := exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ensureNetwork ensures the fluxer-shared network exists
|
||||
func ensureNetwork() error {
|
||||
checkCmd := exec.Command("docker", "network", "ls", "--format", "{{.Name}}")
|
||||
output, err := checkCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list networks: %w", err)
|
||||
}
|
||||
|
||||
networks := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
for _, net := range networks {
|
||||
if net == "fluxer-shared" {
|
||||
fmt.Println("✅ fluxer-shared network already exists")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Creating fluxer-shared network...")
|
||||
createCmd := exec.Command("docker", "network", "create", "fluxer-shared")
|
||||
createCmd.Stdout = os.Stdout
|
||||
createCmd.Stderr = os.Stderr
|
||||
if err := createCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to create network: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("✅ fluxer-shared network created")
|
||||
return nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"fluxer.dev/dev/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultGeoIPDir = "dev/geoip"
|
||||
DefaultGeoIPFile = "country_asn.mmdb"
|
||||
)
|
||||
|
||||
// DownloadGeoIP downloads the GeoIP database from IPInfo
|
||||
func DownloadGeoIP(tokenFlag, envPath string) error {
|
||||
token := strings.TrimSpace(tokenFlag)
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("IPINFO_TOKEN"))
|
||||
}
|
||||
|
||||
if token == "" && envPath != "" {
|
||||
env, err := utils.ParseEnvFile(envPath)
|
||||
if err == nil {
|
||||
token = strings.TrimSpace(env["IPINFO_TOKEN"])
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return errors.New("IPInfo token required; provide via --token, IPINFO_TOKEN env var, or the config/env")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(DefaultGeoIPDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
outPath := filepath.Join(DefaultGeoIPDir, DefaultGeoIPFile)
|
||||
u := fmt.Sprintf("https://ipinfo.io/data/free/country_asn.mmdb?token=%s", url.QueryEscape(token))
|
||||
|
||||
fmt.Printf("Downloading GeoIP database to %s...\n", outPath)
|
||||
|
||||
resp, err := http.Get(u)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download GeoIP db: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("unexpected response (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
f, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errors.New("downloaded GeoIP file is empty; check your IPInfo token")
|
||||
}
|
||||
|
||||
fmt.Printf("✅ GeoIP database downloaded (%d bytes).\n", n)
|
||||
fmt.Println()
|
||||
fmt.Println("If you're running a GeoIP service container, restart it so it picks up the new database.")
|
||||
return nil
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package integrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WriteLivekitFileFromEnv writes LiveKit configuration from environment variables
|
||||
func WriteLivekitFileFromEnv(path string, env map[string]string) (bool, error) {
|
||||
voiceEnabled := strings.ToLower(strings.TrimSpace(env["VOICE_ENABLED"])) == "true"
|
||||
if !voiceEnabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(env["LIVEKIT_API_KEY"])
|
||||
apiSecret := strings.TrimSpace(env["LIVEKIT_API_SECRET"])
|
||||
webhookURL := strings.TrimSpace(env["LIVEKIT_WEBHOOK_URL"])
|
||||
|
||||
if apiKey == "" || apiSecret == "" || webhookURL == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
redisURL := strings.TrimSpace(env["REDIS_URL"])
|
||||
redisAddr := strings.TrimPrefix(redisURL, "redis://")
|
||||
if redisAddr == "" {
|
||||
redisAddr = "redis:6379"
|
||||
}
|
||||
|
||||
yaml := fmt.Sprintf(`port: 7880
|
||||
|
||||
redis:
|
||||
address: "%s"
|
||||
db: 0
|
||||
|
||||
keys:
|
||||
"%s": "%s"
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: "%s"
|
||||
urls:
|
||||
- "%s"
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
`, redisAddr, apiKey, apiSecret, apiKey, webhookURL)
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileExists checks if a file exists at the given path
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// BoolString converts a boolean to a string ("true" or "false")
|
||||
func BoolString(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
// FirstNonZeroInt returns the first non-zero integer from the provided values,
|
||||
// or the default value if all are zero
|
||||
func FirstNonZeroInt(values ...int) int {
|
||||
for _, v := range values {
|
||||
if v != 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DefaultString returns the value if non-empty, otherwise returns the default
|
||||
func DefaultString(value, defaultValue string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RandomString generates a random alphanumeric string of the given length
|
||||
func RandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// RandomBase32 generates a random base32-encoded string (without padding)
|
||||
func RandomBase32(byteLength int) string {
|
||||
b := make([]byte, byteLength)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strings.TrimRight(base32.StdEncoding.EncodeToString(b), "=")
|
||||
}
|
||||
|
||||
// GenerateSnowflake generates a snowflake ID
|
||||
// Format: timestamp (42 bits) + worker ID (10 bits) + sequence (12 bits)
|
||||
func GenerateSnowflake() string {
|
||||
const fluxerEpoch = 1420070400000
|
||||
timestamp := time.Now().UnixMilli() - fluxerEpoch
|
||||
workerID := int64(0)
|
||||
sequence := int64(0)
|
||||
snowflake := (timestamp << 22) | (workerID << 12) | sequence
|
||||
return fmt.Sprintf("%d", snowflake)
|
||||
}
|
||||
|
||||
// ValidateURL validates that a string is a valid URL
|
||||
func ValidateURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
if parsedURL.Scheme == "" {
|
||||
return fmt.Errorf("URL must have a scheme (http:// or https://)")
|
||||
}
|
||||
if parsedURL.Host == "" {
|
||||
return fmt.Errorf("URL must have a host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseEnvFile parses a .env file and returns a map of key-value pairs
|
||||
func ParseEnvFile(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
env := make(map[string]string)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
return env, scanner.Err()
|
||||
}
|
||||
154
dev/setup.sh
154
dev/setup.sh
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (C) 2026 Fluxer Contributors
|
||||
#
|
||||
# This file is part of Fluxer.
|
||||
#
|
||||
# Fluxer is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Fluxer is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
check_command() {
|
||||
if command -v "$1" &> /dev/null; then
|
||||
echo -e "${GREEN}[OK]${NC} $1 is installed"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}[MISSING]${NC} $1 is not installed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_node_version() {
|
||||
if command -v node &> /dev/null; then
|
||||
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_VERSION" -ge 20 ]; then
|
||||
echo -e "${GREEN}[OK]${NC} Node.js $(node -v) is installed"
|
||||
return 0
|
||||
else
|
||||
echo -e "${YELLOW}[WARN]${NC} Node.js $(node -v) is installed, but v20+ is recommended"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}[MISSING]${NC} Node.js is not installed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Fluxer Development Setup ==="
|
||||
echo ""
|
||||
|
||||
echo "Checking prerequisites..."
|
||||
echo ""
|
||||
|
||||
MISSING=0
|
||||
|
||||
check_node_version || MISSING=1
|
||||
check_command pnpm || MISSING=1
|
||||
check_command docker || MISSING=1
|
||||
check_command rustc || echo -e "${YELLOW}[OPTIONAL]${NC} Rust is not installed (needed for fluxer_app WASM modules)"
|
||||
check_command wasm-pack || echo -e "${YELLOW}[OPTIONAL]${NC} wasm-pack is not installed (needed for fluxer_app WASM modules)"
|
||||
check_command go || echo -e "${YELLOW}[OPTIONAL]${NC} Go is not installed (needed for fluxer_geoip)"
|
||||
|
||||
echo ""
|
||||
|
||||
if [ "$MISSING" -eq 1 ]; then
|
||||
echo -e "${RED}Some required dependencies are missing. Please install them before continuing.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Creating Docker network if needed..."
|
||||
if docker network inspect fluxer-shared &> /dev/null; then
|
||||
echo -e "${GREEN}[OK]${NC} Docker network 'fluxer-shared' already exists"
|
||||
else
|
||||
docker network create fluxer-shared
|
||||
echo -e "${GREEN}[OK]${NC} Created Docker network 'fluxer-shared'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/.env" ]; then
|
||||
echo "Creating .env from .env.example..."
|
||||
cp "$SCRIPT_DIR/.env.example" "$SCRIPT_DIR/.env"
|
||||
echo -e "${GREEN}[OK]${NC} Created .env file"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} .env file already exists"
|
||||
fi
|
||||
|
||||
mkdir -p "$SCRIPT_DIR/geoip_data"
|
||||
if [ ! -f "$SCRIPT_DIR/geoip_data/ipinfo_lite.mmdb" ]; then
|
||||
echo -e "${YELLOW}[INFO]${NC} GeoIP database not found."
|
||||
echo " Set IPINFO_TOKEN in .env and run the geoip service to download it,"
|
||||
echo " or manually download ipinfo_lite.mmdb to dev/geoip_data/"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} GeoIP database exists"
|
||||
fi
|
||||
|
||||
if [ ! -f "$SCRIPT_DIR/livekit.yaml" ]; then
|
||||
echo "Creating default livekit.yaml..."
|
||||
cat > "$SCRIPT_DIR/livekit.yaml" << 'EOF'
|
||||
port: 7880
|
||||
|
||||
redis:
|
||||
address: 'redis:6379'
|
||||
db: 0
|
||||
|
||||
keys:
|
||||
'e1dG953yAoJPIsK1dzfTWAKMNE9gmnPL': 'rCtIICXHtAwSAJ4glb11jARcXCCgMTGvvTKLIlpD0pEoANLgjCNPD1Ysm8uWhQTB'
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: 'e1dG953yAoJPIsK1dzfTWAKMNE9gmnPL'
|
||||
urls:
|
||||
- 'http://api:8080/webhooks/livekit'
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
EOF
|
||||
echo -e "${GREEN}[OK]${NC} Created livekit.yaml"
|
||||
else
|
||||
echo -e "${GREEN}[OK]${NC} livekit.yaml already exists"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. Start data stores:"
|
||||
echo " docker compose -f compose.data.yaml up -d"
|
||||
echo ""
|
||||
echo "2. Start app services:"
|
||||
echo " docker compose up -d api worker media gateway admin marketing docs geoip metrics caddy"
|
||||
echo ""
|
||||
echo "3. Run the frontend on your host machine:"
|
||||
echo " cd ../fluxer_app && pnpm install && pnpm dev"
|
||||
echo ""
|
||||
echo "4. Access the app at: http://localhost:8088"
|
||||
echo ""
|
||||
echo "Optional: Start Cloudflare tunnel:"
|
||||
echo " docker compose up -d cloudflared"
|
||||
echo ""
|
||||
@@ -55,7 +55,7 @@ pub type UserSession {
|
||||
client_ip: String,
|
||||
client_os: String,
|
||||
client_platform: String,
|
||||
client_location: String,
|
||||
client_location: Option(String),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -635,7 +635,7 @@ pub fn list_user_sessions(
|
||||
use client_ip <- decode.field("client_ip", decode.string)
|
||||
use client_os <- decode.field("client_os", decode.string)
|
||||
use client_platform <- decode.field("client_platform", decode.string)
|
||||
use client_location <- decode.field("client_location", decode.string)
|
||||
use client_location <- decode.field("client_location", decode.optional(decode.string))
|
||||
decode.success(UserSession(
|
||||
session_id_hash: session_id_hash,
|
||||
created_at: created_at,
|
||||
|
||||
@@ -429,7 +429,6 @@ fn render_pending_verification_card(
|
||||
can_review: Bool,
|
||||
) -> element.Element(a) {
|
||||
let metadata_warning = user_agent_warning(pv.metadata)
|
||||
let geoip_hint = geoip_reason_value(pv.metadata)
|
||||
|
||||
h.div(
|
||||
[
|
||||
@@ -493,10 +492,6 @@ fn render_pending_verification_card(
|
||||
option.Some(msg) -> ui.pill(msg, ui.PillWarning)
|
||||
option.None -> element.none()
|
||||
},
|
||||
case geoip_hint {
|
||||
option.Some(hint) -> ui.pill("GeoIP: " <> hint, ui.PillInfo)
|
||||
option.None -> element.none()
|
||||
},
|
||||
]),
|
||||
]),
|
||||
h.details(
|
||||
@@ -606,9 +601,6 @@ fn render_registration_metadata(
|
||||
False -> ip <> " (Normalized: " <> normalized_ip <> ")"
|
||||
}
|
||||
|
||||
let geoip_reason =
|
||||
option_or_default("none", metadata_value(metadata, "geoip_reason"))
|
||||
|
||||
let os = option_or_default("Unknown", metadata_value(metadata, "os"))
|
||||
|
||||
let browser =
|
||||
@@ -627,14 +619,6 @@ fn render_registration_metadata(
|
||||
|
||||
let ip_reverse = metadata_value(metadata, "ip_address_reverse")
|
||||
|
||||
let geoip_note = case geoip_reason {
|
||||
"none" -> element.none()
|
||||
reason ->
|
||||
h.div([a.class("text-xs text-neutral-500")], [
|
||||
element.text("GeoIP hint: " <> reason),
|
||||
])
|
||||
}
|
||||
|
||||
h.div([a.class("flex flex-col gap-0.5 text-xs text-neutral-600")], [
|
||||
h.div([], [element.text("Display Name: " <> display_name)]),
|
||||
h.div([], [element.text("IP: " <> ip_display)]),
|
||||
@@ -644,7 +628,6 @@ fn render_registration_metadata(
|
||||
h.div([], [element.text("Reverse DNS: " <> reverse)])
|
||||
option.None -> element.none()
|
||||
},
|
||||
geoip_note,
|
||||
h.div([], [element.text("OS: " <> os)]),
|
||||
h.div([], [element.text("Browser: " <> browser)]),
|
||||
h.div([], [element.text("Device: " <> device)]),
|
||||
@@ -705,19 +688,6 @@ fn metadata_value(
|
||||
})
|
||||
}
|
||||
|
||||
fn geoip_reason_value(
|
||||
metadata: List(verifications.PendingVerificationMetadata),
|
||||
) -> option.Option(String) {
|
||||
case metadata_value(metadata, "geoip_reason") {
|
||||
option.Some(reason) ->
|
||||
case reason {
|
||||
"none" -> option.None
|
||||
r -> option.Some(r)
|
||||
}
|
||||
option.None -> option.None
|
||||
}
|
||||
}
|
||||
|
||||
fn option_or_default(default: String, value: option.Option(String)) -> String {
|
||||
case value {
|
||||
option.Some(v) -> v
|
||||
|
||||
@@ -209,7 +209,10 @@ pub fn account_tab(
|
||||
],
|
||||
),
|
||||
h.div([a.class("text-neutral-900")], [
|
||||
element.text(session_item.client_location),
|
||||
case session_item.client_location {
|
||||
option.Some(location) -> element.text(location)
|
||||
option.None -> element.text("Unknown")
|
||||
},
|
||||
]),
|
||||
]),
|
||||
h.div([], [
|
||||
|
||||
@@ -29,7 +29,6 @@ import {AuthController} from '~/auth/AuthController';
|
||||
import {Config} from '~/Config';
|
||||
import {ChannelController} from '~/channel/ChannelController';
|
||||
import type {StreamPreviewService} from '~/channel/services/StreamPreviewService';
|
||||
import {DebugController} from '~/debug/DebugController';
|
||||
import {DownloadController} from '~/download/DownloadController';
|
||||
import {AppErrorHandler, AppNotFoundHandler} from '~/Errors';
|
||||
import {InvalidApiOriginError} from '~/errors/InvalidApiOriginError';
|
||||
@@ -230,7 +229,6 @@ routes.get('/_health', async (ctx) => ctx.text('OK'));
|
||||
|
||||
GatewayController(routes);
|
||||
|
||||
DebugController(routes);
|
||||
registerAdminControllers(routes);
|
||||
AuthController(routes);
|
||||
ChannelController(routes);
|
||||
|
||||
@@ -114,8 +114,6 @@ const ConfigSchema = z.object({
|
||||
}),
|
||||
|
||||
geoip: z.object({
|
||||
provider: z.enum(['ipinfo', 'maxmind']),
|
||||
host: z.string().optional(),
|
||||
maxmindDbPath: z.string().optional(),
|
||||
}),
|
||||
|
||||
@@ -313,12 +311,7 @@ function loadConfig() {
|
||||
: Array.from(new Set([apiPublicEndpoint, webAppEndpoint, apiClientEndpoint]));
|
||||
|
||||
const testModeEnabled = optionalBool('FLUXER_TEST_MODE');
|
||||
const geoipProviderRaw = optional('GEOIP_PROVIDER')?.trim().toLowerCase();
|
||||
const geoipProvider = geoipProviderRaw === 'maxmind' ? 'maxmind' : 'ipinfo';
|
||||
const maxmindDbPath = optional('MAXMIND_DB_PATH');
|
||||
if (geoipProvider === 'maxmind' && !maxmindDbPath) {
|
||||
throw new Error('Missing required environment variable: MAXMIND_DB_PATH');
|
||||
}
|
||||
|
||||
return ConfigSchema.parse({
|
||||
nodeEnv: optional('NODE_ENV') || 'development',
|
||||
@@ -353,8 +346,6 @@ function loadConfig() {
|
||||
},
|
||||
|
||||
geoip: {
|
||||
provider: geoipProvider,
|
||||
host: optional('GEOIP_HOST') || 'geoip',
|
||||
maxmindDbPath,
|
||||
},
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ export const mapUserToAdminResponse = async (user: User, cacheService?: ICacheSe
|
||||
let lastActiveLocation: string | null = null;
|
||||
if (user.lastActiveIp) {
|
||||
try {
|
||||
const geoip = await IpUtils.getCountryCodeDetailed(user.lastActiveIp);
|
||||
const geoip = await IpUtils.lookupGeoip(user.lastActiveIp);
|
||||
const formattedLocation = IpUtils.formatGeoipLocation(geoip);
|
||||
lastActiveLocation = formattedLocation === IpUtils.UNKNOWN_LOCATION ? null : formattedLocation;
|
||||
lastActiveLocation = formattedLocation;
|
||||
} catch {
|
||||
lastActiveLocation = null;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import type {IEmailService} from '~/infrastructure/IEmailService';
|
||||
import type {BotMfaMirrorService} from '~/oauth/BotMfaMirrorService';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import type {UserContactChangeLogService} from '~/user/services/UserContactChangeLogService';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
|
||||
import type {
|
||||
BulkUpdateUserFlagsRequest,
|
||||
DisableForSuspiciousActivityRequest,
|
||||
@@ -350,6 +352,9 @@ export class AdminUserSecurityService {
|
||||
}
|
||||
|
||||
const sessions = await userRepository.listAuthSessions(userIdTyped);
|
||||
const locationResults = await Promise.allSettled(
|
||||
sessions.map((session) => IpUtils.getLocationLabelFromIp(session.clientIp)),
|
||||
);
|
||||
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
@@ -361,15 +366,23 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessions.map((session) => ({
|
||||
session_id_hash: session.sessionIdHash.toString('base64url'),
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
|
||||
client_ip: session.clientIp,
|
||||
client_os: session.clientOs,
|
||||
client_platform: session.clientPlatform,
|
||||
client_location: session.clientLocation ?? 'Unknown Location',
|
||||
})),
|
||||
sessions: sessions.map((session, index) => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: session.clientUserAgent,
|
||||
isDesktopClient: session.clientIsDesktop,
|
||||
});
|
||||
return {
|
||||
session_id_hash: session.sessionIdHash.toString('base64url'),
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt.toISOString(),
|
||||
client_ip: session.clientIp,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_location: clientLocation,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import type {AuthSession} from '~/Models';
|
||||
import {createStringType, EmailType, GlobalNameType, PasswordType, UsernameType, z} from '~/Schema';
|
||||
import {UNKNOWN_LOCATION} from '~/utils/IpUtils';
|
||||
import {getLocationLabelFromIp} from '~/utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
|
||||
|
||||
export const RegisterRequest = z.object({
|
||||
email: EmailType.optional(),
|
||||
@@ -80,26 +81,45 @@ export const VerifyEmailRequest = z.object({
|
||||
|
||||
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequest>;
|
||||
|
||||
export const mapAuthSessionsToResponse = ({
|
||||
async function resolveAuthSessionLocation(session: AuthSession): Promise<string | null> {
|
||||
try {
|
||||
return await getLocationLabelFromIp(session.clientIp);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const mapAuthSessionsToResponse = async ({
|
||||
authSessions,
|
||||
}: {
|
||||
authSessions: Array<AuthSession>;
|
||||
}): Array<AuthSessionResponse> => {
|
||||
return authSessions
|
||||
.sort((a, b) => {
|
||||
const aTime = a.approximateLastUsedAt?.getTime() || 0;
|
||||
const bTime = b.approximateLastUsedAt?.getTime() || 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.map((authSession): AuthSessionResponse => {
|
||||
return {
|
||||
id: uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}),
|
||||
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
|
||||
client_os: authSession.clientOs,
|
||||
client_platform: authSession.clientPlatform,
|
||||
client_location: authSession.clientLocation ?? UNKNOWN_LOCATION,
|
||||
};
|
||||
}): Promise<Array<AuthSessionResponse>> => {
|
||||
const sortedSessions = [...authSessions].sort((a, b) => {
|
||||
const aTime = a.approximateLastUsedAt?.getTime() || 0;
|
||||
const bTime = b.approximateLastUsedAt?.getTime() || 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
const locationResults = await Promise.allSettled(
|
||||
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
|
||||
);
|
||||
|
||||
return sortedSessions.map((authSession, index): AuthSessionResponse => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
isDesktopClient: authSession.clientIsDesktop,
|
||||
});
|
||||
|
||||
return {
|
||||
id: uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true}),
|
||||
approx_last_used_at: authSession.approximateLastUsedAt?.toISOString() || null,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_location: clientLocation,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const AuthSessionResponse = z.object({
|
||||
@@ -107,7 +127,7 @@ export const AuthSessionResponse = z.object({
|
||||
approx_last_used_at: z.iso.datetime().nullish(),
|
||||
client_os: z.string(),
|
||||
client_platform: z.string(),
|
||||
client_location: z.string(),
|
||||
client_location: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type AuthSessionResponse = z.infer<typeof AuthSessionResponse>;
|
||||
|
||||
@@ -331,8 +331,8 @@ export class AuthLoginService {
|
||||
if (!isIpAuthorized) {
|
||||
const ticket = createIpAuthorizationTicket(await this.generateSecureToken());
|
||||
const authToken = createIpAuthorizationToken(await this.generateSecureToken());
|
||||
const geoipResult = await IpUtils.getCountryCodeDetailed(clientIp);
|
||||
const clientLocation = IpUtils.formatGeoipLocation(geoipResult);
|
||||
const geoipResult = await IpUtils.lookupGeoip(clientIp);
|
||||
const clientLocation = IpUtils.formatGeoipLocation(geoipResult) ?? IpUtils.UNKNOWN_LOCATION;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platform = request.headers.get('x-fluxer-platform');
|
||||
|
||||
|
||||
@@ -83,8 +83,6 @@ const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
|
||||
const DEFAULT_MINIMUM_AGE = 13;
|
||||
const USER_AGENT_TRUNCATE_LENGTH = 512;
|
||||
|
||||
type CountryResultDetailed = Awaited<ReturnType<typeof IpUtils.getCountryCodeDetailed>>;
|
||||
|
||||
interface RegistrationMetadataContext {
|
||||
metadata: Map<string, string>;
|
||||
clientIp: string;
|
||||
@@ -120,11 +118,6 @@ function determineAgeGroup(age: number | null): string {
|
||||
return '65+';
|
||||
}
|
||||
|
||||
function sanitizeEmail(email: string | null | undefined): {raw: string | null; key: string | null} {
|
||||
const key = email ? email.toLowerCase() : null;
|
||||
return {raw: email ?? null, key};
|
||||
}
|
||||
|
||||
function isIpv6(ip: string): boolean {
|
||||
return ip.includes(':');
|
||||
}
|
||||
@@ -189,8 +182,8 @@ export class AuthRegistrationService {
|
||||
const metrics = getMetricsService();
|
||||
|
||||
const clientIp = IpUtils.requireClientIp(request);
|
||||
const countryCode = await IpUtils.getCountryCodeFromReq(request);
|
||||
const countryResultDetailed = await IpUtils.getCountryCodeDetailed(clientIp);
|
||||
const geoipResult = await IpUtils.lookupGeoip(clientIp);
|
||||
const countryCode = geoipResult.countryCode;
|
||||
|
||||
const minAge = (countryCode && MINIMUM_AGE_BY_COUNTRY[countryCode]) || DEFAULT_MINIMUM_AGE;
|
||||
if (!this.validateAge({dateOfBirth: data.date_of_birth, minAge})) {
|
||||
@@ -204,7 +197,8 @@ export class AuthRegistrationService {
|
||||
throw InputValidationError.create('password', 'Password is too common');
|
||||
}
|
||||
|
||||
const {raw: rawEmail, key: emailKey} = sanitizeEmail(data.email);
|
||||
const rawEmail = data.email ?? null;
|
||||
const emailKey = rawEmail ? rawEmail.toLowerCase() : null;
|
||||
|
||||
const enforceRateLimits = !Config.dev.relaxRegistrationRateLimits;
|
||||
await this.enforceRegistrationRateLimits({enforceRateLimits, clientIp, emailKey});
|
||||
@@ -300,7 +294,7 @@ export class AuthRegistrationService {
|
||||
name: 'user.registration',
|
||||
dimensions: {
|
||||
country: countryCode ?? 'unknown',
|
||||
state: countryResultDetailed.region ?? 'unknown',
|
||||
state: geoipResult.region ?? 'unknown',
|
||||
ip_version: isIpv6(clientIp) ? 'v6' : 'v4',
|
||||
},
|
||||
});
|
||||
@@ -310,7 +304,7 @@ export class AuthRegistrationService {
|
||||
name: 'user.age',
|
||||
dimensions: {
|
||||
country: countryCode ?? 'unknown',
|
||||
state: countryResultDetailed.region ?? 'unknown',
|
||||
state: geoipResult.region ?? 'unknown',
|
||||
age: age !== null ? age.toString() : 'unknown',
|
||||
age_group: determineAgeGroup(age),
|
||||
},
|
||||
@@ -333,7 +327,7 @@ export class AuthRegistrationService {
|
||||
user,
|
||||
clientIp,
|
||||
request,
|
||||
countryResultDetailed,
|
||||
geoipResult,
|
||||
});
|
||||
|
||||
if (isPendingVerification)
|
||||
@@ -542,9 +536,9 @@ export class AuthRegistrationService {
|
||||
user: User;
|
||||
clientIp: string;
|
||||
request: Request;
|
||||
countryResultDetailed: CountryResultDetailed;
|
||||
geoipResult: IpUtils.GeoipResult;
|
||||
}): Promise<RegistrationMetadataContext> {
|
||||
const {user, clientIp, request, countryResultDetailed} = params;
|
||||
const {user, clientIp, request, geoipResult} = params;
|
||||
|
||||
const userAgentHeader = (request.headers.get('user-agent') ?? '').trim();
|
||||
const fluxerTag = `${user.username}#${user.discriminator.toString().padStart(4, '0')}`;
|
||||
@@ -559,9 +553,9 @@ export class AuthRegistrationService {
|
||||
? this.parseUserAgentSafe(userAgentHeader)
|
||||
: {osInfo: 'Unknown', browserInfo: 'Unknown', deviceInfo: 'Desktop/Unknown'};
|
||||
|
||||
const normalizedIp = countryResultDetailed.normalizedIp ?? clientIp;
|
||||
const geoipReason = countryResultDetailed.reason ?? 'none';
|
||||
const locationLabel = IpUtils.formatGeoipLocation(countryResultDetailed);
|
||||
const normalizedIp = geoipResult.normalizedIp ?? clientIp;
|
||||
const locationLabel = IpUtils.formatGeoipLocation(geoipResult) ?? IpUtils.UNKNOWN_LOCATION;
|
||||
const safeCountryCode = geoipResult.countryCode ?? 'unknown';
|
||||
const ipAddressReverse = await IpUtils.getIpAddressReverse(normalizedIp, this.cacheService);
|
||||
|
||||
const metadataEntries: Array<[string, string]> = [
|
||||
@@ -570,27 +564,26 @@ export class AuthRegistrationService {
|
||||
['email', emailDisplay],
|
||||
['ip_address', clientIp],
|
||||
['normalized_ip', normalizedIp],
|
||||
['country_code', countryResultDetailed.countryCode],
|
||||
['country_code', safeCountryCode],
|
||||
['location', locationLabel],
|
||||
['geoip_reason', geoipReason],
|
||||
['os', uaInfo.osInfo],
|
||||
['browser', uaInfo.browserInfo],
|
||||
['device', uaInfo.deviceInfo],
|
||||
['user_agent', truncatedUserAgent],
|
||||
];
|
||||
|
||||
if (countryResultDetailed.city) metadataEntries.push(['city', countryResultDetailed.city]);
|
||||
if (countryResultDetailed.region) metadataEntries.push(['region', countryResultDetailed.region]);
|
||||
if (countryResultDetailed.countryName) metadataEntries.push(['country_name', countryResultDetailed.countryName]);
|
||||
if (geoipResult.city) metadataEntries.push(['city', geoipResult.city]);
|
||||
if (geoipResult.region) metadataEntries.push(['region', geoipResult.region]);
|
||||
if (geoipResult.countryName) metadataEntries.push(['country_name', geoipResult.countryName]);
|
||||
if (ipAddressReverse) metadataEntries.push(['ip_address_reverse', ipAddressReverse]);
|
||||
|
||||
return {
|
||||
metadata: new Map(metadataEntries),
|
||||
clientIp,
|
||||
countryCode: countryResultDetailed.countryCode,
|
||||
countryCode: safeCountryCode,
|
||||
location: locationLabel,
|
||||
city: countryResultDetailed.city,
|
||||
region: countryResultDetailed.region,
|
||||
city: geoipResult.city,
|
||||
region: geoipResult.region,
|
||||
osInfo: uaInfo.osInfo,
|
||||
browserInfo: uaInfo.browserInfo,
|
||||
deviceInfo: uaInfo.deviceInfo,
|
||||
|
||||
@@ -17,12 +17,10 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {type AuthSessionResponse, mapAuthSessionsToResponse} from '~/auth/AuthModel';
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {AccessDeniedError} from '~/Errors';
|
||||
import type {IGatewayService} from '~/infrastructure/IGatewayService';
|
||||
import {Logger} from '~/Logger';
|
||||
import type {AuthSession, User} from '~/Models';
|
||||
import type {IUserRepository} from '~/user/IUserRepository';
|
||||
import * as IpUtils from '~/utils/IpUtils';
|
||||
@@ -41,45 +39,6 @@ interface UpdateUserActivityParams {
|
||||
userId: UserID;
|
||||
clientIp: string;
|
||||
}
|
||||
|
||||
function formatNameVersion(name?: string | null, version?: string | null): string {
|
||||
if (!name) return 'Unknown';
|
||||
if (!version) return name;
|
||||
return `${name} ${version}`;
|
||||
}
|
||||
|
||||
function nullIfUnknown(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return value === IpUtils.UNKNOWN_LOCATION ? null : value;
|
||||
}
|
||||
|
||||
function parseUserAgent(userAgentRaw: string): {clientOs: string; detectedPlatform: string} {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: 'Unknown', detectedPlatform: 'Unknown'};
|
||||
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
const osName = parser.getOSName() || 'Unknown';
|
||||
const osVersion = parser.getOSVersion() || null;
|
||||
const browserName = parser.getBrowserName() || 'Unknown';
|
||||
const browserVersion = parser.getBrowserVersion() || null;
|
||||
|
||||
return {
|
||||
clientOs: formatNameVersion(osName, osVersion),
|
||||
detectedPlatform: formatNameVersion(browserName, browserVersion),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: 'Unknown', detectedPlatform: 'Unknown'};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClientPlatform(platformHeader: string | null, detectedPlatform: string): string {
|
||||
if (!platformHeader) return detectedPlatform;
|
||||
if (platformHeader === 'desktop') return 'Fluxer Desktop';
|
||||
return detectedPlatform;
|
||||
}
|
||||
|
||||
export class AuthSessionService {
|
||||
constructor(
|
||||
private repository: IUserRepository,
|
||||
@@ -97,11 +56,7 @@ export class AuthSessionService {
|
||||
|
||||
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
|
||||
const uaRaw = request.headers.get('user-agent') ?? '';
|
||||
const uaInfo = parseUserAgent(uaRaw);
|
||||
|
||||
const geoip = await IpUtils.getCountryCodeDetailed(ip);
|
||||
const locationLabel = nullIfUnknown(IpUtils.formatGeoipLocation(geoip));
|
||||
const countryLabel = nullIfUnknown(geoip.countryName ?? geoip.countryCode ?? null);
|
||||
const isDesktopClient = platformHeader === 'desktop';
|
||||
|
||||
const authSession = await this.repository.createAuthSession({
|
||||
user_id: user.id,
|
||||
@@ -109,10 +64,8 @@ export class AuthSessionService {
|
||||
created_at: now,
|
||||
approx_last_used_at: now,
|
||||
client_ip: ip,
|
||||
client_os: uaInfo.clientOs,
|
||||
client_platform: resolveClientPlatform(platformHeader, uaInfo.detectedPlatform),
|
||||
client_country: countryLabel,
|
||||
client_location: locationLabel,
|
||||
client_user_agent: uaRaw || null,
|
||||
client_is_desktop: isDesktopClient,
|
||||
version: 1,
|
||||
});
|
||||
|
||||
@@ -125,7 +78,7 @@ export class AuthSessionService {
|
||||
|
||||
async getAuthSessions(userId: UserID): Promise<Array<AuthSessionResponse>> {
|
||||
const authSessions = await this.repository.listAuthSessions(userId);
|
||||
return mapAuthSessionsToResponse({authSessions});
|
||||
return await mapAuthSessionsToResponse({authSessions});
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(tokenHash: Uint8Array): Promise<void> {
|
||||
|
||||
@@ -35,10 +35,8 @@ export interface AuthSessionRow {
|
||||
created_at: Date;
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_os: string;
|
||||
client_platform: string;
|
||||
client_country: Nullish<string>;
|
||||
client_location: Nullish<string>;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -142,10 +140,8 @@ export const AUTH_SESSION_COLUMNS = [
|
||||
'created_at',
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'client_location',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {HonoApp} from '~/App';
|
||||
import {Config} from '~/Config';
|
||||
import {DEFAULT_CC, extractClientIp, formatGeoipLocation, getCountryCodeDetailed} from '~/utils/IpUtils';
|
||||
|
||||
export const DebugController = (app: HonoApp) => {
|
||||
app.get('/_debug/geoip', async (ctx) => {
|
||||
const manualIp = ctx.req.query('ip')?.trim() || null;
|
||||
const headerIp = extractClientIp(ctx.req.raw);
|
||||
const chosenIp = manualIp || headerIp;
|
||||
|
||||
let countryCode = DEFAULT_CC;
|
||||
let normalizedIp: string | null = null;
|
||||
let error: string | null = null;
|
||||
let reason: string | null = null;
|
||||
let geoipLocation: string | null = null;
|
||||
|
||||
if (chosenIp) {
|
||||
try {
|
||||
const result = await getCountryCodeDetailed(chosenIp);
|
||||
countryCode = result.countryCode;
|
||||
normalizedIp = result.normalizedIp;
|
||||
reason = result.reason;
|
||||
geoipLocation = formatGeoipLocation(result);
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
x_forwarded_for: ctx.req.header('x-forwarded-for') || null,
|
||||
ip: chosenIp,
|
||||
manual_ip: manualIp,
|
||||
extracted_ip: headerIp,
|
||||
country_code: countryCode,
|
||||
normalized_ip: normalizedIp,
|
||||
reason,
|
||||
geoip_host: Config.geoip.host || null,
|
||||
geoip_provider: Config.geoip.provider,
|
||||
geoip_location: geoipLocation,
|
||||
default_cc: DEFAULT_CC,
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -26,10 +26,8 @@ export class AuthSession {
|
||||
readonly createdAt: Date;
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientOs: string;
|
||||
readonly clientPlatform: string;
|
||||
readonly clientCountry: string | null;
|
||||
readonly clientLocation: string | null;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly version: number;
|
||||
|
||||
constructor(row: AuthSessionRow) {
|
||||
@@ -38,10 +36,8 @@ export class AuthSession {
|
||||
this.createdAt = row.created_at;
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientOs = row.client_os;
|
||||
this.clientPlatform = row.client_platform;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.clientLocation = row.client_location ?? null;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
|
||||
@@ -52,10 +48,8 @@ export class AuthSession {
|
||||
created_at: this.createdAt,
|
||||
approx_last_used_at: this.approximateLastUsedAt,
|
||||
client_ip: this.clientIp,
|
||||
client_os: this.clientOs,
|
||||
client_platform: this.clientPlatform,
|
||||
client_country: this.clientCountry,
|
||||
client_location: this.clientLocation,
|
||||
client_user_agent: this.clientUserAgent,
|
||||
client_is_desktop: this.clientIsDesktop,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ export const RpcRequest = z.discriminatedUnion('type', [
|
||||
type: z.literal('get_badge_counts'),
|
||||
user_ids: z.array(Int64Type),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('geoip_lookup'),
|
||||
ip: createStringType(1, 45),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('delete_push_subscriptions'),
|
||||
subscriptions: z.array(
|
||||
@@ -290,6 +294,10 @@ export const RpcResponse = z.discriminatedUnion('type', [
|
||||
type: z.literal('validate_custom_status'),
|
||||
data: RpcResponseValidateCustomStatus,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('geoip_lookup'),
|
||||
data: z.object({country_code: z.string()}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('get_dm_channel'),
|
||||
data: z.object({
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
import {isUserAdult} from '~/utils/AgeUtils';
|
||||
import {deriveDominantAvatarColor} from '~/utils/AvatarColorUtils';
|
||||
import {calculateDistance, parseCoordinate} from '~/utils/GeoUtils';
|
||||
import {formatGeoipLocation, getCountryCodeDetailed} from '~/utils/IpUtils';
|
||||
import {lookupGeoip} from '~/utils/IpUtils';
|
||||
import type {VoiceAccessContext, VoiceAvailabilityService} from '~/voice/VoiceAvailabilityService';
|
||||
import type {VoiceService} from '~/voice/VoiceService';
|
||||
import type {IWebhookRepository} from '~/webhook/IWebhookRepository';
|
||||
@@ -284,6 +284,15 @@ export class RpcService {
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'geoip_lookup': {
|
||||
const geoip = await lookupGeoip(request.ip);
|
||||
return {
|
||||
type: 'geoip_lookup',
|
||||
data: {
|
||||
country_code: geoip.countryCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'delete_push_subscriptions':
|
||||
return {
|
||||
type: 'delete_push_subscriptions',
|
||||
@@ -678,24 +687,9 @@ export class RpcService {
|
||||
});
|
||||
|
||||
let countryCode = 'US';
|
||||
let geoipReason: string | null = null;
|
||||
if (ip) {
|
||||
const geoip = await getCountryCodeDetailed(ip);
|
||||
const geoip = await lookupGeoip(ip);
|
||||
countryCode = geoip.countryCode;
|
||||
geoipReason = geoip.reason;
|
||||
if (geoipReason) {
|
||||
Logger.warn(
|
||||
{
|
||||
ip,
|
||||
normalized_ip: geoip.normalizedIp,
|
||||
reason: geoipReason,
|
||||
geoip_host: Config.geoip.host,
|
||||
geoip_provider: Config.geoip.provider,
|
||||
geoip_location: formatGeoipLocation(geoip),
|
||||
},
|
||||
'GeoIP lookup fell back to default country code',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Logger.warn({context: 'rpc_geoip', reason: 'ip_missing'}, 'RPC session request missing IP for GeoIP');
|
||||
}
|
||||
|
||||
@@ -74,8 +74,6 @@ process.env.CLAMAV_ENABLED = 'false';
|
||||
process.env.FLUXER_APP_HOST = 'localhost:3000';
|
||||
process.env.FLUXER_APP_PROTOCOL = 'http';
|
||||
|
||||
process.env.GEOIP_HOST = 'geoip.test';
|
||||
process.env.GEOIP_PROVIDER = 'ipinfo';
|
||||
process.env.SUDO_MODE_SECRET = 'test-sudo-secret';
|
||||
|
||||
import {vi} from 'vitest';
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {__clearIpCache, DEFAULT_CC, FETCH_TIMEOUT_MS, formatGeoipLocation, getCountryCode} from './IpUtils';
|
||||
|
||||
vi.mock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: 'geoip:8080'}}}));
|
||||
|
||||
describe('getCountryCode(ip)', () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
__clearIpCache();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns US if GEOIP_HOST is not set', async () => {
|
||||
vi.doMock('~/Config', () => ({Config: {geoip: {provider: 'ipinfo', host: ''}}}));
|
||||
const {getCountryCode: modFn, __clearIpCache: reset} = await import('./IpUtils');
|
||||
reset();
|
||||
const cc = await modFn('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('returns US for invalid IP', async () => {
|
||||
const cc = await getCountryCode('not_an_ip');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('accepts bracketed IPv6', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('[2001:db8::1]');
|
||||
expect(cc).toBe('SE');
|
||||
});
|
||||
|
||||
it('returns uppercase alpha-2 on success', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe('SE');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back on non-2xx', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: false, text: () => Promise.resolve('se')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('falls back on invalid body', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('USA')});
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('falls back on network error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const cc = await getCountryCode('8.8.8.8');
|
||||
expect(cc).toBe(DEFAULT_CC);
|
||||
});
|
||||
|
||||
it('uses cache for repeated lookups within TTL', async () => {
|
||||
const spyNow = vi.spyOn(Date, 'now');
|
||||
spyNow.mockReturnValueOnce(1_000);
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true, text: () => Promise.resolve('gb')});
|
||||
const r1 = await getCountryCode('1.1.1.1');
|
||||
expect(r1).toBe('GB');
|
||||
spyNow.mockReturnValueOnce(2_000);
|
||||
const r2 = await getCountryCode('1.1.1.1');
|
||||
expect(r2).toBe('GB');
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('aborts after timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortingFetch = vi.fn<typeof fetch>(
|
||||
(_url, opts) =>
|
||||
new Promise((_res, rej) => {
|
||||
opts?.signal?.addEventListener('abort', () =>
|
||||
rej(Object.assign(new Error('AbortError'), {name: 'AbortError'})),
|
||||
);
|
||||
}),
|
||||
);
|
||||
globalThis.fetch = abortingFetch;
|
||||
const p = getCountryCode('8.8.8.8');
|
||||
vi.advanceTimersByTime(FETCH_TIMEOUT_MS + 5);
|
||||
await expect(p).resolves.toBe(DEFAULT_CC);
|
||||
expect(abortingFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatGeoipLocation(result)', () => {
|
||||
it('returns Unknown Location when no parts are available', () => {
|
||||
const label = formatGeoipLocation({
|
||||
countryCode: DEFAULT_CC,
|
||||
normalizedIp: '1.1.1.1',
|
||||
reason: null,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
});
|
||||
expect(label).toBe('Unknown Location');
|
||||
});
|
||||
|
||||
it('concatenates available city, region, and country', () => {
|
||||
const label = formatGeoipLocation({
|
||||
countryCode: 'US',
|
||||
normalizedIp: '1.1.1.1',
|
||||
reason: null,
|
||||
city: 'San Francisco',
|
||||
region: 'CA',
|
||||
countryName: 'United States',
|
||||
});
|
||||
expect(label).toBe('San Francisco, CA, United States');
|
||||
});
|
||||
});
|
||||
@@ -25,42 +25,43 @@ import type {ICacheService} from '~/infrastructure/ICacheService';
|
||||
import {Logger} from '~/Logger';
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const DEFAULT_CC = 'US';
|
||||
export const FETCH_TIMEOUT_MS = 1500;
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
const REVERSE_DNS_CACHE_TTL_SECONDS = 24 * 60 * 60;
|
||||
const REVERSE_DNS_CACHE_PREFIX = 'reverse-dns:';
|
||||
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
|
||||
export interface GeoipResult {
|
||||
countryCode: string;
|
||||
countryCode: string | null;
|
||||
normalizedIp: string | null;
|
||||
reason: string | null;
|
||||
city: string | null;
|
||||
region: string | null;
|
||||
countryName: string | null;
|
||||
}
|
||||
|
||||
interface CacheVal {
|
||||
type CacheEntry = {
|
||||
result: GeoipResult;
|
||||
exp: number;
|
||||
}
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheVal>();
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
|
||||
|
||||
export function __clearIpCache(): void {
|
||||
cache.clear();
|
||||
geoipCache.clear();
|
||||
}
|
||||
|
||||
export function __resetMaxmindReader(): void {
|
||||
maxmindReader = null;
|
||||
maxmindReaderPromise = null;
|
||||
}
|
||||
|
||||
export function extractClientIp(req: Request): string | null {
|
||||
const xff = (req.headers.get('X-Forwarded-For') ?? '').trim();
|
||||
if (!xff) {
|
||||
return null;
|
||||
}
|
||||
const first = xff.split(',')[0]?.trim() ?? '';
|
||||
if (!xff) return null;
|
||||
const [first] = xff.split(',');
|
||||
if (!first) return null;
|
||||
return normalizeIpString(first);
|
||||
}
|
||||
|
||||
@@ -72,24 +73,33 @@ export function requireClientIp(req: Request): string {
|
||||
return ip;
|
||||
}
|
||||
|
||||
function buildFallbackResult(clean: string, reason: string | null): GeoipResult {
|
||||
export async function lookupGeoip(req: Request): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(ip: string): Promise<GeoipResult>;
|
||||
export async function lookupGeoip(input: string | Request): Promise<GeoipResult> {
|
||||
const ip = typeof input === 'string' ? input : extractClientIp(input);
|
||||
if (!ip) {
|
||||
return buildFallbackResult('');
|
||||
}
|
||||
return lookupGeoipFromString(ip);
|
||||
}
|
||||
|
||||
function buildFallbackResult(clean: string): GeoipResult {
|
||||
return {
|
||||
countryCode: DEFAULT_CC,
|
||||
normalizedIp: clean,
|
||||
reason,
|
||||
countryCode: null,
|
||||
normalizedIp: clean || null,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
async function ensureMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
if (maxmindReader) return maxmindReader;
|
||||
|
||||
if (!maxmindReaderPromise) {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
return Promise.reject(new Error('Missing MaxMind DB path'));
|
||||
throw new Error('Missing MaxMind DB path');
|
||||
}
|
||||
|
||||
maxmindReaderPromise = maxmind
|
||||
@@ -107,127 +117,79 @@ async function getMaxmindReader(): Promise<Reader<CityResponse>> {
|
||||
return maxmindReaderPromise;
|
||||
}
|
||||
|
||||
function getSubdivisionLabel(record?: CityResponse): string | null {
|
||||
function stateLabel(record?: CityResponse): string | null {
|
||||
const subdivision = record?.subdivisions?.[0];
|
||||
if (!subdivision) return null;
|
||||
return subdivision.iso_code || subdivision.names?.en || null;
|
||||
return subdivision.names?.en || subdivision.iso_code || null;
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string): Promise<GeoipResult> {
|
||||
const dbPath = Config.geoip.maxmindDbPath;
|
||||
if (!dbPath) {
|
||||
return buildFallbackResult(clean, 'maxmind_db_missing');
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
try {
|
||||
const reader = await getMaxmindReader();
|
||||
const reader = await ensureMaxmindReader();
|
||||
const record = reader.get(clean);
|
||||
if (!record) {
|
||||
return buildFallbackResult(clean, 'maxmind_not_found');
|
||||
}
|
||||
if (!record) return buildFallbackResult(clean);
|
||||
|
||||
const isoCode = record.country?.iso_code;
|
||||
const countryCode = isoCode ? isoCode.toUpperCase() : null;
|
||||
|
||||
const countryCode = (record.country?.iso_code ?? DEFAULT_CC).toUpperCase();
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: clean,
|
||||
reason: null,
|
||||
city: record.city?.names?.en ?? null,
|
||||
region: getSubdivisionLabel(record),
|
||||
countryName: record.country?.names?.en ?? countryDisplayName(countryCode) ?? null,
|
||||
region: stateLabel(record),
|
||||
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message ?? 'unknown';
|
||||
Logger.warn({error, maxmind_db_path: dbPath}, 'MaxMind lookup failed');
|
||||
return buildFallbackResult(clean, `maxmind_error:${message}`);
|
||||
const message = (error as Error).message ?? 'unknown';
|
||||
Logger.warn({error, maxmind_db_path: dbPath, message}, 'MaxMind lookup failed');
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupIpinfo(clean: string): Promise<GeoipResult> {
|
||||
const host = Config.geoip.host;
|
||||
if (!host) {
|
||||
return buildFallbackResult(clean, 'geoip_host_missing');
|
||||
}
|
||||
|
||||
const url = `http://${host}/lookup?ip=${encodeURIComponent(clean)}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await globalThis.fetch!(url, {
|
||||
signal: controller.signal,
|
||||
headers: {Accept: 'text/plain'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return buildFallbackResult(clean, `non_ok:${res.status}`);
|
||||
}
|
||||
|
||||
const text = (await res.text()).trim().toUpperCase();
|
||||
const countryCode = isAsciiUpperAlpha2(text) ? text : DEFAULT_CC;
|
||||
const reason = isAsciiUpperAlpha2(text) ? null : 'invalid_response';
|
||||
return {
|
||||
countryCode,
|
||||
normalizedIp: clean,
|
||||
reason,
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: countryDisplayName(countryCode),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = (error as Error)?.message ?? 'unknown';
|
||||
return buildFallbackResult(clean, `error:${message}`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCountryCodeDetailed(ip: string): Promise<GeoipResult> {
|
||||
const clean = normalizeIpString(ip);
|
||||
if (!isIPv4(clean) && !isIPv6(clean)) {
|
||||
return buildFallbackResult(clean, 'invalid_ip');
|
||||
}
|
||||
|
||||
const cached = cache.get(clean);
|
||||
async function resolveGeoip(clean: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.exp) {
|
||||
const cached = geoipCache.get(clean);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const provider = Config.geoip.provider;
|
||||
const result = provider === 'maxmind' ? await lookupMaxmind(clean) : await lookupIpinfo(clean);
|
||||
|
||||
cache.set(clean, {result, exp: now + CACHE_TTL_MS});
|
||||
const result = await lookupMaxmind(clean);
|
||||
geoipCache.set(clean, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCountryCode(ip: string): Promise<string> {
|
||||
const result = await getCountryCodeDetailed(ip);
|
||||
return result.countryCode;
|
||||
}
|
||||
async function lookupGeoipFromString(value: string): Promise<GeoipResult> {
|
||||
const clean = normalizeIpString(value);
|
||||
if (!isIPv4(clean) && !isIPv6(clean)) {
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
|
||||
export async function getCountryCodeFromReq(req: Request): Promise<string> {
|
||||
const ip = extractClientIp(req);
|
||||
if (!ip) return DEFAULT_CC;
|
||||
return await getCountryCode(ip);
|
||||
return resolveGeoip(clean);
|
||||
}
|
||||
|
||||
function countryDisplayName(code: string, locale = 'en'): string | null {
|
||||
const c = code.toUpperCase();
|
||||
if (!isAsciiUpperAlpha2(c)) return null;
|
||||
const upper = code.toUpperCase();
|
||||
if (!isAsciiUpperAlpha2(upper)) return null;
|
||||
const dn = new Intl.DisplayNames([locale], {type: 'region', fallback: 'none'});
|
||||
return dn.of(c) ?? null;
|
||||
return dn.of(upper) ?? null;
|
||||
}
|
||||
|
||||
export function formatGeoipLocation(result: GeoipResult): string {
|
||||
export function formatGeoipLocation(result: GeoipResult): string | null {
|
||||
const parts: Array<string> = [];
|
||||
if (result.city) parts.push(result.city);
|
||||
if (result.region) parts.push(result.region);
|
||||
const countryLabel = result.countryName ?? result.countryCode;
|
||||
if (countryLabel) parts.push(countryLabel);
|
||||
return parts.length > 0 ? parts.join(', ') : UNKNOWN_LOCATION;
|
||||
return parts.length > 0 ? parts.join(', ') : null;
|
||||
}
|
||||
|
||||
function stripBrackets(s: string): string {
|
||||
return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
|
||||
function stripBrackets(value: string): string {
|
||||
return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
|
||||
}
|
||||
|
||||
export function normalizeIpString(value: string): string {
|
||||
@@ -239,12 +201,9 @@ export function normalizeIpString(value: string): string {
|
||||
|
||||
export async function getIpAddressReverse(ip: string, cacheService?: ICacheService): Promise<string | null> {
|
||||
const cacheKey = `${REVERSE_DNS_CACHE_PREFIX}${ip}`;
|
||||
|
||||
if (cacheService) {
|
||||
const cached = await cacheService.get<string | null>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached === '' ? null : cached;
|
||||
}
|
||||
if (cached !== null) return cached === '' ? null : cached;
|
||||
}
|
||||
|
||||
let result: string | null = null;
|
||||
@@ -262,9 +221,17 @@ export async function getIpAddressReverse(ip: string, cacheService?: ICacheServi
|
||||
return result;
|
||||
}
|
||||
|
||||
function isAsciiUpperAlpha2(s: string): boolean {
|
||||
if (s.length !== 2) return false;
|
||||
const a = s.charCodeAt(0);
|
||||
const b = s.charCodeAt(1);
|
||||
return a >= 65 && a <= 90 && b >= 65 && b <= 90;
|
||||
export async function getLocationLabelFromIp(ip: string): Promise<string | null> {
|
||||
const result = await lookupGeoip(ip);
|
||||
return formatGeoipLocation(result);
|
||||
}
|
||||
|
||||
function isAsciiUpperAlpha2(value: string): boolean {
|
||||
return (
|
||||
value.length === 2 &&
|
||||
value.charCodeAt(0) >= 65 &&
|
||||
value.charCodeAt(0) <= 90 &&
|
||||
value.charCodeAt(1) >= 65 &&
|
||||
value.charCodeAt(1) <= 90
|
||||
);
|
||||
}
|
||||
|
||||
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal file
61
fluxer_api/src/utils/UserAgentUtils.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Bowser from 'bowser';
|
||||
import {Logger} from '~/Logger';
|
||||
|
||||
export interface UserAgentInfo {
|
||||
clientOs: string;
|
||||
detectedPlatform: string;
|
||||
}
|
||||
|
||||
const UNKNOWN_LABEL = 'Unknown';
|
||||
|
||||
function formatName(name?: string | null): string {
|
||||
const normalized = name?.trim();
|
||||
return normalized || UNKNOWN_LABEL;
|
||||
}
|
||||
|
||||
export function parseUserAgentSafe(userAgentRaw: string): UserAgentInfo {
|
||||
const ua = userAgentRaw.trim();
|
||||
if (!ua) return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
|
||||
try {
|
||||
const parser = Bowser.getParser(ua);
|
||||
return {
|
||||
clientOs: formatName(parser.getOSName()),
|
||||
detectedPlatform: formatName(parser.getBrowserName()),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Failed to parse user agent');
|
||||
return {clientOs: UNKNOWN_LABEL, detectedPlatform: UNKNOWN_LABEL};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionClientInfo(args: {userAgent: string | null; isDesktopClient: boolean | null}): {
|
||||
clientOs: string;
|
||||
clientPlatform: string;
|
||||
} {
|
||||
const parsed = parseUserAgentSafe(args.userAgent ?? '');
|
||||
const clientPlatform = args.isDesktopClient ? 'Fluxer Desktop' : parsed.detectedPlatform;
|
||||
return {
|
||||
clientOs: parsed.clientOs,
|
||||
clientPlatform,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {Config} from '~/Config';
|
||||
import {makeAttachmentCdnUrl} from '~/channel/services/message/MessageHelpers';
|
||||
import {Logger} from '~/Logger';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
import {resolveSessionClientInfo} from '~/utils/UserAgentUtils';
|
||||
import {appendAssetToArchive, buildHashedAssetKey, getAnimatedAssetExtension} from '../utils/AssetArchiveHelpers';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
@@ -324,15 +325,20 @@ const harvestUserData: Task = async (payload, helpers) => {
|
||||
mfa_enabled: user.authenticatorTypes.size > 0,
|
||||
authenticator_types: Array.from(user.authenticatorTypes),
|
||||
},
|
||||
auth_sessions: authSessions.map((session) => ({
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
|
||||
client_ip: session.clientIp,
|
||||
client_os: session.clientOs,
|
||||
client_platform: session.clientPlatform,
|
||||
client_country: session.clientCountry,
|
||||
client_location: session.clientLocation,
|
||||
})),
|
||||
auth_sessions: authSessions.map((session) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: session.clientUserAgent,
|
||||
isDesktopClient: session.clientIsDesktop,
|
||||
});
|
||||
return {
|
||||
created_at: session.createdAt.toISOString(),
|
||||
approx_last_used_at: session.approximateLastUsedAt?.toISOString() ?? null,
|
||||
client_ip: session.clientIp,
|
||||
client_os: clientOs,
|
||||
client_user_agent: session.clientUserAgent,
|
||||
client_platform: clientPlatform,
|
||||
};
|
||||
}),
|
||||
relationships: relationships.map((rel) => ({
|
||||
target_user_id: rel.targetUserId.toString(),
|
||||
type: rel.type,
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var allowedChannels = []string{"stable", "canary"}
|
||||
|
||||
func parseChannel() string {
|
||||
raw := os.Getenv("BUILD_CHANNEL")
|
||||
if raw != "" && slices.Contains(allowedChannels, raw) {
|
||||
return raw
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func main() {
|
||||
channel := parseChannel()
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
targetPath := filepath.Join(cwd, "..", "src-electron", "common", "build-channel.ts")
|
||||
|
||||
fileContent := fmt.Sprintf(`/*
|
||||
* This file is generated by scripts/cmd/set-build-channel.
|
||||
* DO NOT EDIT MANUALLY.
|
||||
*/
|
||||
|
||||
export type BuildChannel = 'stable' | 'canary';
|
||||
|
||||
const DEFAULT_BUILD_CHANNEL = '%s' as BuildChannel;
|
||||
|
||||
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
|
||||
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
|
||||
export const IS_CANARY = BUILD_CHANNEL === 'canary';
|
||||
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
|
||||
`, channel)
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte(fileContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Wrote %s with channel '%s'\n", targetPath, channel)
|
||||
}
|
||||
@@ -97,6 +97,9 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
|
||||
const platformLabel =
|
||||
authSession.clientPlatform === 'Fluxer Desktop' ? t`Fluxer Desktop` : authSession.clientPlatform;
|
||||
|
||||
const hasLocation = Boolean(authSession.clientLocation);
|
||||
const locationRowVisible = hasLocation || !isCurrent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.authSession, selectionMode && !isCurrent && styles.authSessionSelectable)}
|
||||
@@ -114,17 +117,17 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
|
||||
{platformLabel}
|
||||
</span>
|
||||
|
||||
<div className={styles.authSessionLocation}>
|
||||
<span className={styles.locationText}>{authSession.clientLocation}</span>
|
||||
{!isCurrent && (
|
||||
<>
|
||||
<span aria-hidden className={styles.locationSeparator} />
|
||||
{locationRowVisible && (
|
||||
<div className={styles.authSessionLocation}>
|
||||
{hasLocation && <span className={styles.locationText}>{authSession.clientLocation}</span>}
|
||||
{!isCurrent && hasLocation && <span aria-hidden className={styles.locationSeparator} />}
|
||||
{!isCurrent && (
|
||||
<span className={styles.lastUsed}>
|
||||
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AuthSession = Readonly<{
|
||||
approx_last_used_at: string | null;
|
||||
client_os: string;
|
||||
client_platform: string;
|
||||
client_location: string;
|
||||
client_location: string | null;
|
||||
}>;
|
||||
|
||||
export class AuthSessionRecord {
|
||||
@@ -30,7 +30,7 @@ export class AuthSessionRecord {
|
||||
readonly approxLastUsedAt: Date | null;
|
||||
readonly clientOs: string;
|
||||
readonly clientPlatform: string;
|
||||
readonly clientLocation: string;
|
||||
readonly clientLocation: string | null;
|
||||
|
||||
constructor(data: AuthSession) {
|
||||
this.id = data.id;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE fluxer.auth_sessions ADD client_user_agent text;
|
||||
ALTER TABLE fluxer.auth_sessions ADD client_is_desktop boolean;
|
||||
@@ -1,21 +0,0 @@
|
||||
FROM golang:1.25.5 AS builder
|
||||
WORKDIR /src
|
||||
|
||||
ENV CGO_ENABLED=0 GOOS=linux
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN go build -trimpath -buildvcs=false -ldflags="-s -w" -o /out/fluxer_geoip ./...
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
WORKDIR /app
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
COPY --from=builder /out/fluxer_geoip /app/fluxer_geoip
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app/fluxer_geoip"]
|
||||
@@ -1,8 +0,0 @@
|
||||
module fluxer_geoip
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0 h1:Gyljxck1kHbBxDgLM++NfDWBqvu1pWWfT8XbosSo0bo=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0/go.mod h1:gG4V88LsawPEqtbL1Veh1WRh+nVSYwXzJ1P5Fcn77g0=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
@@ -1,190 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
maxminddb "github.com/oschwald/maxminddb-golang/v2"
|
||||
)
|
||||
|
||||
type cfg struct {
|
||||
Port string
|
||||
DBPath string
|
||||
TTL time.Duration
|
||||
Cap int
|
||||
}
|
||||
|
||||
type liteRecord struct {
|
||||
CountryCode string `maxminddb:"country_code"`
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
val string
|
||||
exp time.Time
|
||||
}
|
||||
type lru struct {
|
||||
mu sync.Mutex
|
||||
data map[string]cacheEntry
|
||||
order []string
|
||||
ttl time.Duration
|
||||
cap int
|
||||
}
|
||||
|
||||
func newLRU(ttl time.Duration, cap int) *lru {
|
||||
return &lru{data: make(map[string]cacheEntry, cap), order: make([]string, 0, cap), ttl: ttl, cap: cap}
|
||||
}
|
||||
func (c *lru) get(k string) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ent, ok := c.data[k]
|
||||
if !ok || time.Now().After(ent.exp) {
|
||||
if ok {
|
||||
delete(c.data, k)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
for i, v := range c.order {
|
||||
if v == k {
|
||||
copy(c.order[i:], c.order[i+1:])
|
||||
c.order[len(c.order)-1] = k
|
||||
break
|
||||
}
|
||||
}
|
||||
return ent.val, true
|
||||
}
|
||||
func (c *lru) put(k, v string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, exists := c.data[k]; !exists && len(c.data) >= c.cap {
|
||||
if len(c.order) > 0 {
|
||||
ev := c.order[0]
|
||||
delete(c.data, ev)
|
||||
c.order = c.order[1:]
|
||||
}
|
||||
}
|
||||
c.data[k] = cacheEntry{val: v, exp: time.Now().Add(c.ttl)}
|
||||
for i, v2 := range c.order {
|
||||
if v2 == k {
|
||||
copy(c.order[i:], c.order[i+1:])
|
||||
c.order = c.order[:len(c.order)-1]
|
||||
break
|
||||
}
|
||||
}
|
||||
c.order = append(c.order, k)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultVal int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, defaultVal time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func main() {
|
||||
c := cfg{
|
||||
Port: getEnv("FLUXER_GEOIP_PORT", "8080"),
|
||||
DBPath: getEnv("GEOIP_DB_PATH", "/data/ipinfo_lite.mmdb"),
|
||||
TTL: getEnvDuration("GEOIP_CACHE_TTL", 10*time.Minute),
|
||||
Cap: getEnvInt("GEOIP_CACHE_SIZE", 20000),
|
||||
}
|
||||
|
||||
db, err := maxminddb.Open(c.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open mmdb: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
cache := newLRU(c.TTL, c.Cap)
|
||||
|
||||
http.HandleFunc("/_health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
http.HandleFunc("/lookup", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
|
||||
ipStr := r.URL.Query().Get("ip")
|
||||
if ipStr == "" {
|
||||
http.Error(w, "missing 'ip' query param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(strings.Trim(ipStr, "[]")))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid ip", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
key := addr.String()
|
||||
if v, ok := cache.get(key); ok {
|
||||
if v == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(v))
|
||||
return
|
||||
}
|
||||
|
||||
var rec liteRecord
|
||||
if err := db.Lookup(addr).Decode(&rec); err != nil {
|
||||
http.Error(w, "lookup error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
cc := strings.TrimSpace(rec.CountryCode)
|
||||
cache.put(key, cc)
|
||||
|
||||
if cc == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(cc))
|
||||
})
|
||||
|
||||
log.Printf("geoip-lite (text+cache) listening on :%s (ttl=%s cap=%d)", c.Port, c.TTL, c.Cap)
|
||||
log.Fatal(http.ListenAndServe(":"+c.Port, nil))
|
||||
}
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "أزرق (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "وضع مضغوط وخيارات العرض"
|
||||
msgid "Company"
|
||||
msgstr "الشركة"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "معلومات الشركة"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "تواصل"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "كيف تخطط لاستخدام Fluxer مع مجتمعك"
|
||||
msgid "Hungarian"
|
||||
msgstr "المجرية"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "بيانات تحديد الموقع الجغرافي عبر عنوان IP من "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "افتح Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "افتح في المتصفح"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "جاهز للترقية؟"
|
||||
msgid "Register now"
|
||||
msgstr "سجّل الآن"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "أبلِغ عن خلل"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "نتائج البحث عن: "
|
||||
msgid "Search through message history"
|
||||
msgstr "البحث في سجل الرسائل"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "لوحة الأصوات"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "الكود المصدري"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "هذا ليس بديلاً كاملًا لتطبيق سطح المكتب
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "هذه الصفحة غير موجودة، لكن هناك الكثير لاستكشافه."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "يتضمن هذا المنتج بيانات GeoLite2 التي أنشأتها MaxMind، والمتاحة من "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "سلاسل المحادثة والمنتديات"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "نتائج لـ"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "تبقّى {0} من أصل {1} مقعدًا"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (شركة سويدية ذات مسؤولية محدودة: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Синьо (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Компактен режим и опции за показване"
|
||||
msgid "Company"
|
||||
msgstr "Компания"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Информация за компанията"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Свържете се"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Как планирате да използвате Fluxer с вашат
|
||||
msgid "Hungarian"
|
||||
msgstr "Унгарски"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Данни за IP геолокация от "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Отворете Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Отваряне в браузъра"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Готови ли сте да надградите?"
|
||||
msgid "Register now"
|
||||
msgstr "Регистрирайте се сега"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Докладвайте бъг"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Резултати от търсенето за: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Търсене в историята на съобщенията"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Изходен код"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Това все още не е пълен заместител на на
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Тази страница не съществува. Но има още много за разглеждане."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Това изделие включва GeoLite2 данни, създадени от MaxMind, налични от "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Нишки и форуми"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "резултати за"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Остават {0} от {1} места"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (шведско дружество с ограничена отговорност: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Modrá (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompaktní režim a možnosti zobrazení"
|
||||
msgid "Company"
|
||||
msgstr "Společnost"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informace o společnosti"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Spojit se"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Jak plánujete používat Fluxer se svou komunitou"
|
||||
msgid "Hungarian"
|
||||
msgstr "Maďarština"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Geolokační data podle IP od "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Otevřít Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Otevřít v prohlížeči"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Jste připraveni na upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Zaregistrujte se"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Nahlásit chybu"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Výsledky vyhledávání pro: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Vyhledávání v historii zpráv"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Zvukový panel"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Zdrojový kód"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Zatím to není plnohodnotná náhrada desktopové aplikace a některé
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Tato stránka neexistuje. Ale je tu spousta dalšího k prozkoumání."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Tento produkt obsahuje data GeoLite2 vytvořená společností MaxMind, dostupná z "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Vlákna a fóra"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "výsledky pro"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Zbývá {0} z {1} slotů"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (švédská společnost s ručením omezeným: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blå (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompakt tilstand og visningsindstillinger"
|
||||
msgid "Company"
|
||||
msgstr "Virksomhed"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Virksomhedsoplysninger"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kontakt"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Hvordan du planlægger at bruge Fluxer med dit fællesskab"
|
||||
msgid "Hungarian"
|
||||
msgstr "Ungarsk"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-geolokationsdata leveret af "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Åbn Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Åbn i browseren"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Klar til at opgradere?"
|
||||
msgid "Register now"
|
||||
msgstr "Tilmeld dig nu"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Rapportér en fejl"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Søgeresultater for: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Søg i beskedhistorik"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Kildekode"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Dette er endnu ikke en fuld erstatning for desktop-appen, og nogle ting,
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Denne side findes ikke. Men der er masser mere at udforske."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Dette produkt inkluderer GeoLite2-data oprettet af MaxMind, tilgængelig fra "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Tråde og fora"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultater for"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} af {1} pladser tilbage"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (svensk aktieselskab: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blau (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompaktmodus und Anzeigeoptionen"
|
||||
msgid "Company"
|
||||
msgstr "Unternehmen"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Unternehmensinformationen"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kontakt"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Wie du Fluxer in deiner Community nutzen möchtest"
|
||||
msgid "Hungarian"
|
||||
msgstr "Ungarisch"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-Geolokalisierungsdaten von "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer öffnen"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Im Browser öffnen"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Bereit fürs Upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Jetzt registrieren"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Bug melden"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Suchergebnisse für: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Nachrichtenverlauf durchsuchen"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Quellcode"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Das ist noch kein vollständiger Ersatz für die Desktop-App – und ein
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Diese Seite gibt es nicht. Aber es gibt noch viel mehr zu entdecken."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Dieses Produkt enthält GeoLite2-Daten, die von MaxMind erstellt wurden, verfügbar unter "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Threads und Foren"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "Ergebnisse für"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} von {1} Plätzen übrig"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (schwedische GmbH: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Μπλε (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Συμπαγής λειτουργία και επιλογές εμφάν
|
||||
msgid "Company"
|
||||
msgstr "Εταιρεία"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Πληροφορίες εταιρείας"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Συνδέσου"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Πώς σκοπεύεις να χρησιμοποιήσεις το Fluxe
|
||||
msgid "Hungarian"
|
||||
msgstr "Ουγγρικά"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Δεδομένα γεωεντοπισμού IP από "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Άνοιξε το Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Άνοιξε στο πρόγραμμα περιήγησης"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Έτοιμος/έτοιμη για αναβάθμιση;"
|
||||
msgid "Register now"
|
||||
msgstr "Κάνε εγγραφή τώρα"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Ανάφερε bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Αποτελέσματα αναζήτησης για: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Αναζήτηση στο ιστορικό μηνυμάτων"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Πίνακας ήχων"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Πηγαίος κώδικας"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Αυτό δεν αντικαθιστά πλήρως ακόμη την ε
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Αυτή η σελίδα δεν υπάρχει. Αλλά υπάρχουν πολλά ακόμα να εξερευνήσεις."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Αυτό το προϊόν περιλαμβάνει δεδομένα GeoLite2 που δημιουργήθηκαν από την MaxMind, διαθέσιμα από "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Νήματα και φόρουμ"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "αποτελέσματα για"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Απομένουν {0} από {1} θέσεις"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (σουηδική ανώνυμη εταιρεία: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blue (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Compact mode and display options"
|
||||
msgid "Company"
|
||||
msgstr "Company"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Company information"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Connect"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "How you plan to use Fluxer with your community"
|
||||
msgid "Hungarian"
|
||||
msgstr "Hungarian"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP geolocation data by "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Open Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Open in browser"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Ready to upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Register now"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Report a bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Search results for: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Search through message history"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Source code"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "This isn't a full replacement for the desktop app yet, and some things y
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "This page doesn't exist. But there's plenty more to explore."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Threads and forums"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "results for"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} of {1} slots left"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Azul (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Modo compacto y opciones de visualización"
|
||||
msgid "Company"
|
||||
msgstr "Empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Información de la empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Conéctate"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Cómo planeas usar Fluxer con tu comunidad"
|
||||
msgid "Hungarian"
|
||||
msgstr "Húngaro"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Datos de geolocalización por IP de "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Abrir Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Abrir en el navegador"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "¿Listo para mejorar tu plan?"
|
||||
msgid "Register now"
|
||||
msgstr "Regístrate ahora"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Reportar un bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Resultados de búsqueda para: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Busca en el historial de mensajes"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Panel de sonidos"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Código fuente"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Todavía no reemplaza por completo a la app de escritorio, y aún faltan
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Esta página no existe, pero hay mucho más por explorar."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Este producto incluye datos de GeoLite2 creados por MaxMind, disponibles en "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Hilos y foros"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultados para"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Quedan {0} de {1} cupos"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (sociedad de responsabilidad limitada sueca: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Azul (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Modo compacto y opciones de visualización"
|
||||
msgid "Company"
|
||||
msgstr "Empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Información de la empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Conectar"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Cómo piensas usar Fluxer con tu comunidad"
|
||||
msgid "Hungarian"
|
||||
msgstr "Húngaro"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Datos de geolocalización por IP de "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Abrir Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Abrir en el navegador"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "¿Listo para mejorar?"
|
||||
msgid "Register now"
|
||||
msgstr "Regístrate ahora"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Reportar un error"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Resultados de búsqueda para: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Busca en el historial de mensajes"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Panel de sonidos"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Código fuente"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Aún no sustituye por completo a la app de escritorio, y todavía faltan
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Esta página no existe. Pero aún hay mucho más por explorar."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Este producto incluye datos de GeoLite2 creados por MaxMind, disponibles en "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Hilos y foros"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultados para"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Quedan {0} de {1} plazas"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (sociedad limitada sueca: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Sininen (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompakti tila ja näyttöasetukset"
|
||||
msgid "Company"
|
||||
msgstr "Yritys"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Yritystiedot"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Yhteydet"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Miten aiot käyttää Fluxeria yhteisösi kanssa"
|
||||
msgid "Hungarian"
|
||||
msgstr "unkari"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-paikannustiedot tarjoaa "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Avaa Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Avaa selaimessa"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Valmiina päivittämään?"
|
||||
msgid "Register now"
|
||||
msgstr "Rekisteröidy nyt"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Ilmoita virheestä"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Hakutulokset haulle: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Hae viestihistoriasta"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Äänipaneeli"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Lähdekoodi"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Tämä ei vielä täysin korvaa työpöytäsovellusta, ja osa mobiilicha
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Tätä sivua ei ole olemassa. Mutta tutkittavaa riittää vielä paljon."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Tämä tuote sisältää GeoLite2-tiedot, jotka on luonut MaxMind, saatavilla osoitteesta "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Ketjut ja foorumit"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "hakutulokset haulle"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0}/{1} paikkaa jäljellä"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (ruotsalainen osakeyhtiö: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Bleu (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Mode compact et options d’affichage"
|
||||
msgid "Company"
|
||||
msgstr "Entreprise"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informations sur l’entreprise"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Contact"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Comment vous comptez utiliser Fluxer avec votre communauté"
|
||||
msgid "Hungarian"
|
||||
msgstr "Hongrois"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Données de géolocalisation IP fournies par "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Ouvrir Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Ouvrir dans le navigateur"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Prêt à passer à la version supérieure ?"
|
||||
msgid "Register now"
|
||||
msgstr "S’inscrire maintenant"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Signaler un bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Résultats de recherche pour : "
|
||||
msgid "Search through message history"
|
||||
msgstr "Rechercher dans l’historique des messages"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Table de sons"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Code source"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Ce n’est pas encore un remplacement complet de l’app de bureau, et c
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Cette page n’existe pas. Mais il y a encore plein de choses à découvrir."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Ce produit inclut les données GeoLite2 créées par MaxMind, disponibles à partir de "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Fils de discussion et forums"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "résultats pour"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Plus que {0} places sur {1}"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (société à responsabilité limitée suédoise : 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "כחול (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "מצב קומפקטי ואפשרויות תצוגה"
|
||||
msgid "Company"
|
||||
msgstr "חברה"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "מידע על החברה"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "יצירת קשר"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "איך אתם מתכננים להשתמש ב-Fluxer עם הקהילה ש
|
||||
msgid "Hungarian"
|
||||
msgstr "הונגרית"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "נתוני מיקום לפי IP מאת "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "פתחו את Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "פתחו בדפדפן"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "מוכנים לשדרג?"
|
||||
msgid "Register now"
|
||||
msgstr "הירשמו עכשיו"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "דווחו על באג"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "תוצאות חיפוש עבור: "
|
||||
msgid "Search through message history"
|
||||
msgstr "חיפוש בהיסטוריית הודעות"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "לוח צלילים"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "קוד מקור"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "זה עדיין לא תחליף מלא לאפליקציית הדסקטו
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "העמוד הזה לא קיים. אבל יש עוד המון מה לגלות."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "מוצר זה כולל נתוני GeoLite2 שנוצרו על ידי MaxMind, זמינים מ-"
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "שרשורים ופורומים"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "תוצאות עבור"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "נשארו {0} מתוך {1} מקומות"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (חברה שוודית בערבון מוגבל: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "नीला (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "कॉम्पैक्ट मोड और डिस्प्ले
|
||||
msgid "Company"
|
||||
msgstr "कंपनी"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "कंपनी जानकारी"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "जुड़ें"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "आप अपने समुदाय के साथ Fluxer का
|
||||
msgid "Hungarian"
|
||||
msgstr "हंगेरियन"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP जियोलोकेशन डेटा: "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer खोलें"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "ब्राउज़र में खोलें"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "अपग्रेड के लिए तैयार?"
|
||||
msgid "Register now"
|
||||
msgstr "अभी रजिस्टर करें"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "बग रिपोर्ट करें"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "इसके लिए खोज परिणाम: "
|
||||
msgid "Search through message history"
|
||||
msgstr "संदेश इतिहास में खोजें"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "साउंडबोर्ड"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "स्रोत कोड"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "यह अभी डेस्कटॉप ऐप का पूरा व
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "यह पेज मौजूद नहीं है। लेकिन देखने के लिए और भी बहुत कुछ है।"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "यह उत्पाद GeoLite2 डेटा शामिल करता है जिसे MaxMind द्वारा बनाया गया है, उपलब्ध है"
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "थ्रेड्स और फ़ोरम्स"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "परिणाम:"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{1} में से {0} स्लॉट बचे हैं"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (स्वीडन की लिमिटेड लायबिलिटी कंपनी: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Plava (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompaktni način rada i opcije prikaza"
|
||||
msgid "Company"
|
||||
msgstr "Tvrtka"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informacije o tvrtki"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Poveži se"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Kako planirate koristiti Fluxer sa svojom zajednicom"
|
||||
msgid "Hungarian"
|
||||
msgstr "Mađarski"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP geolokacijski podaci od "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Otvori Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Otvori u pregledniku"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Spremni ste za nadogradnju?"
|
||||
msgid "Register now"
|
||||
msgstr "Registrirajte se sada"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Prijavi grešku"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Rezultati pretrage za: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Pretražujte povijest poruka"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Izvorni kod"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Ovo još nije potpuna zamjena za desktop aplikaciju i neke stvari koje b
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Ova stranica ne postoji. Ali ima još puno toga za istražiti."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Ovaj proizvod uključuje GeoLite2 podatke koje je stvorio MaxMind, dostupne na "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Niti i forumi"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "rezultata za"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Preostalo je {0} od {1} mjesta"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (švedsko društvo s ograničenom odgovornošću: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Kék (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompakt mód és megjelenítési beállítások"
|
||||
msgid "Company"
|
||||
msgstr "Cég"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Céginformációk"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kapcsolat"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Hogyan tervezed használni a Fluxert a közösségeddel"
|
||||
msgid "Hungarian"
|
||||
msgstr "Magyar"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-helymeghatározási adatok: "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer megnyitása"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Megnyitás böngészőben"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Készen állsz a váltásra?"
|
||||
msgid "Register now"
|
||||
msgstr "Regisztrálj most"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Hiba jelentése"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Keresési találatok erre: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Keresés az üzenetelőzményekben"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Hangpanel"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Forráskód"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Ez még nem teljes értékű helyettesítője az asztali appnak, és né
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Ez az oldal nem létezik. De bőven van még mit felfedezni."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Ez a termék a MaxMind által létrehozott GeoLite2 adatokat tartalmazza, elérhető innen "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Szálak és fórumok"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "találat erre"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Még {0}/{1} hely van"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (svéd korlátolt felelősségű társaság: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Biru (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Mode ringkas dan opsi tampilan"
|
||||
msgid "Company"
|
||||
msgstr "Perusahaan"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informasi Perusahaan"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Terhubung"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Rencana kamu menggunakan Fluxer untuk komunitasmu"
|
||||
msgid "Hungarian"
|
||||
msgstr "Hungaria"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Data geolokasi IP oleh "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Buka Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Buka di browser"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Siap upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Daftar sekarang"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Laporkan bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Hasil pencarian untuk: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Cari di riwayat pesan"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Kode Sumber"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Ini belum sepenuhnya menggantikan aplikasi desktop, dan beberapa hal yan
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Halaman ini tidak ada. Tapi masih banyak hal lain yang bisa kamu jelajahi."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Produk ini mencakup Data GeoLite2 yang dibuat oleh MaxMind, tersedia dari "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Thread dan forum"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "hasil untuk"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Tersisa {0} dari {1} slot"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (perseroan terbatas Swedia: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blu (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Modalità compatta e opzioni di visualizzazione"
|
||||
msgid "Company"
|
||||
msgstr "Azienda"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informazioni sull’azienda"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Contatti"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Come pensi di usare Fluxer con la tua community"
|
||||
msgid "Hungarian"
|
||||
msgstr "Ungherese"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Dati di geolocalizzazione IP forniti da "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Apri Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Apri nel browser"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Pronto a fare l’upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Registrati ora"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Segnala un bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Risultati di ricerca per: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Cerca nella cronologia dei messaggi"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Codice sorgente"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Non è ancora un sostituto completo dell’app desktop, e mancano ancora
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Questa pagina non esiste. Ma c’è molto altro da scoprire."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Questo prodotto include i dati GeoLite2 creati da MaxMind, disponibili presso "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Thread e forum"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "risultati per"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} posti rimasti su {1}"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (società a responsabilità limitata svedese: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "ブルー(Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "コンパクトモードと表示設定"
|
||||
msgid "Company"
|
||||
msgstr "会社"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "会社情報"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "つながる"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "コミュニティでFluxerをどう使う予定か"
|
||||
msgid "Hungarian"
|
||||
msgstr "ハンガリー語"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IPジオロケーションデータ提供:"
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxerを開く"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "ブラウザで開く"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "アップグレードしますか?"
|
||||
msgid "Register now"
|
||||
msgstr "今すぐ登録"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "バグを報告"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "検索結果:"
|
||||
msgid "Search through message history"
|
||||
msgstr "メッセージ履歴を検索"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "サウンドボード"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "ソースコード"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "まだデスクトップアプリの完全な代替ではなく、モバ
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "このページは存在しません。でも、まだまだ見るものはたくさんあります。"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "この製品には、MaxMindによって作成されたGeoLite2データが含まれています。入手先は"
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "スレッド&フォーラム"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "件の検索結果:"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "残り{0}/{1}枠"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB(スウェーデンの有限責任会社:559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "파랑 (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "컴팩트 모드 및 표시 설정"
|
||||
msgid "Company"
|
||||
msgstr "회사"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "회사 정보"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "연락하기"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "커뮤니티와 함께 Fluxer를 어떻게 활용할 계획인지"
|
||||
msgid "Hungarian"
|
||||
msgstr "헝가리어"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP 위치 데이터 제공: "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer 열기"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "브라우저에서 열기"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "업그레이드할 준비가 되셨나요?"
|
||||
msgid "Register now"
|
||||
msgstr "지금 등록하기"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "버그 신고"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "검색 결과: "
|
||||
msgid "Search through message history"
|
||||
msgstr "메시지 기록 검색"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "사운드보드"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "소스 코드"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "아직 데스크톱 앱을 완전히 대체하진 못하며, 모바일
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "이 페이지는 존재하지 않아요. 그래도 둘러볼 곳은 아직 많이 있습니다."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "이 제품에는 MaxMind에 의해 생성된 GeoLite2 데이터가 포함되어 있으며, 다음에서 이용 가능합니다."
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "스레드 및 포럼"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "개의 결과"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "남은 슬롯: {0}/{1}"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (스웨덴 유한회사: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Mėlyna (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompaktiškas režimas ir rodymo parinktys"
|
||||
msgid "Company"
|
||||
msgstr "Įmonė"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informacija apie įmonę"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Susisiekite"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Kaip planuojate naudoti Fluxer su savo bendruomene"
|
||||
msgid "Hungarian"
|
||||
msgstr "Vengrų"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP geolokacijos duomenis teikia "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Atidaryti Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Atidaryti naršyklėje"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Pasiruošę atsinaujinti?"
|
||||
msgid "Register now"
|
||||
msgstr "Registruokitės dabar"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Pranešti apie klaidą"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Paieškos rezultatai: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Ieškokite žinučių istorijoje"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Garso pultas"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Šaltinio kodas"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Kol kas tai dar nėra pilnavertis kompiuterio programos pakaitalas, o ka
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Šis puslapis neegzistuoja. Tačiau yra daugybė kitų dalykų, kuriuos galite atrasti."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Šiame produkte yra GeoLite2 duomenys, kuriuos sukūrė MaxMind, pasiekiami iš "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Gijos ir forumai"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "rezultatai pagal"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Liko {0} iš {1} vietų"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (Švedijos ribotos atsakomybės bendrovė: 559537-3993)"
|
||||
|
||||
|
||||
@@ -1023,10 +1023,6 @@ msgstr ""
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:340
|
||||
msgid "IP geolocation data by "
|
||||
msgstr ""
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1796,6 +1792,10 @@ msgstr ""
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr ""
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr ""
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr ""
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blauw (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Compacte modus en weergaveopties"
|
||||
msgid "Company"
|
||||
msgstr "Bedrijf"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Bedrijfsinformatie"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Contact"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Hoe je Fluxer met je community wilt gebruiken"
|
||||
msgid "Hungarian"
|
||||
msgstr "Hongaars"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-geolocatiegegevens door "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer openen"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Openen in de browser"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Klaar om te upgraden?"
|
||||
msgid "Register now"
|
||||
msgstr "Nu registreren"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Meld een bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Zoekresultaten voor: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Zoek in je berichtgeschiedenis"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Broncode"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Dit is nog geen volledige vervanging voor de desktopapp, en sommige ding
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Deze pagina bestaat niet. Maar er valt nog genoeg te ontdekken."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Dit product bevat GeoLite2-gegevens gemaakt door MaxMind, beschikbaar van "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Threads en forums"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultaten voor"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Nog {0} van {1} plekken over"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (Zweedse besloten vennootschap: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blå (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompaktmodus og visningsvalg"
|
||||
msgid "Company"
|
||||
msgstr "Selskap"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Selskapsinformasjon"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kontakt"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Hvordan du planlegger å bruke Fluxer i fellesskapet ditt"
|
||||
msgid "Hungarian"
|
||||
msgstr "Ungarsk"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-geolokasjonsdata fra "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Åpne Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Åpne i nettleseren"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Klar for å oppgradere?"
|
||||
msgid "Register now"
|
||||
msgstr "Registrer deg nå"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Rapporter en feil"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Søkeresultater for: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Søk i meldingshistorikken"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Lydbrett"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Kildekode"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Dette er ikke en full erstatning for skrivebordsappen ennå, og noen tin
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Denne siden finnes ikke. Men det er mye mer å utforske."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Dette produktet inkluderer GeoLite2-data laget av MaxMind, tilgjengelig fra "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Tråder og forum"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultater for"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} av {1} plasser igjen"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (svensk aksjeselskap: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Niebieski (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Tryb kompaktowy i opcje wyświetlania"
|
||||
msgid "Company"
|
||||
msgstr "Firma"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informacje o firmie"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kontakt"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Jak planujesz używać Fluxer ze swoją społecznością"
|
||||
msgid "Hungarian"
|
||||
msgstr "Węgierski"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Dane geolokalizacji IP od "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Otwórz Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Otwórz w przeglądarce"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Gotowy na upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Zarejestruj się"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Zgłoś błąd"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Wyniki wyszukiwania dla: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Przeszukuj historię wiadomości"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Panel dźwięków"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Kod źródłowy"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "To jeszcze nie jest pełny zamiennik aplikacji desktopowej, a niektóryc
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Ta strona nie istnieje. Ale jest jeszcze sporo do odkrycia."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Ten produkt zawiera dane GeoLite2 utworzone przez MaxMind, dostępne w "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Wątki i fora"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "wyniki dla"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Zostało {0} z {1} miejsc"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (szwedzka spółka z o.o.: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Azul (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Modo compacto e opções de exibição"
|
||||
msgid "Company"
|
||||
msgstr "Empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informações da empresa"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Conectar"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Como você pretende usar o Fluxer com a sua comunidade"
|
||||
msgid "Hungarian"
|
||||
msgstr "Húngaro"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Dados de geolocalização por IP fornecidos por "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Abrir Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Abrir no navegador"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Pronto para fazer upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Cadastre-se agora"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Relatar bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Resultados da busca por: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Buscar no histórico de mensagens"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Painel de sons"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Código-fonte"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Isso ainda não substitui completamente o app de desktop, e ainda faltam
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Esta página não existe. Mas ainda tem muita coisa para explorar."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Este produto inclui os dados GeoLite2 criados pela MaxMind, disponíveis em "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Tópicos e fóruns"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultados para"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} de {1} vagas restantes"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (sociedade limitada sueca: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Albastru (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Mod compact și opțiuni de afișare"
|
||||
msgid "Company"
|
||||
msgstr "Companie"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Informații despre companie"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Conectează-te"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Cum plănuiești să folosești Fluxer cu comunitatea ta"
|
||||
msgid "Hungarian"
|
||||
msgstr "Maghiară"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Date de geolocalizare IP de la "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Deschide Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Deschide în browser"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Ești gata de upgrade?"
|
||||
msgid "Register now"
|
||||
msgstr "Înregistrează-te acum"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Raportează un bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Rezultatele căutării pentru: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Caută în istoricul mesajelor"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Panou de sunete"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Cod sursă"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Încă nu înlocuiește complet aplicația desktop, iar unele lucruri pe
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Pagina aceasta nu există. Dar mai sunt multe de explorat."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Acest produs include datele GeoLite2 create de MaxMind, disponibile de la "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Fire de discuție și forumuri"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "rezultate pentru"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} din {1} locuri rămase"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (societate cu răspundere limitată din Suedia: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Синий (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Компактный режим и настройки отображен
|
||||
msgid "Company"
|
||||
msgstr "Компания"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "О компании"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Связаться"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Как вы планируете использовать Fluxer в св
|
||||
msgid "Hungarian"
|
||||
msgstr "Венгерский"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Данные IP-геолокации предоставлены "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Открыть Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Открыть в браузере"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Готовы перейти на более высокий тариф?"
|
||||
msgid "Register now"
|
||||
msgstr "Зарегистрироваться"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Сообщить об ошибке"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Результаты поиска по запросу: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Поиск по истории сообщений"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Саундборд"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Исходный код"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Пока это не полноценная замена десктоп-
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Этой страницы нет. Но впереди ещё много интересного."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Этот продукт включает данные GeoLite2, созданные компанией MaxMind, доступные по адресу "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Треды и форумы"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "результатов по запросу"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Осталось {0} из {1} мест"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (шведская компания с ограниченной ответственностью: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Blå (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompakt läge och visningsalternativ"
|
||||
msgid "Company"
|
||||
msgstr "Företag"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Företagsinformation"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kontakt"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Hur du tänker använda Fluxer i din community"
|
||||
msgid "Hungarian"
|
||||
msgstr "Ungerska"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP-geolokaliseringsdata från "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Öppna Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Öppna i webbläsaren"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Redo att uppgradera?"
|
||||
msgid "Register now"
|
||||
msgstr "Registrera dig nu"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Rapportera en bugg"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Sökresultat för: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Sök i meddelandehistoriken"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Soundboard"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Källkod"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Det här ersätter inte datorappen fullt ut ännu, och vissa saker man f
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Den här sidan finns inte. Men det finns mycket mer att utforska."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Denna produkt inkluderar GeoLite2-data skapad av MaxMind, tillgänglig från "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Trådar och forum"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "resultat för"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{0} av {1} platser kvar"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (svenskt aktiebolag, org.nr: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "น้ำเงิน (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "โหมดกะทัดรัดและตัวเลือกก
|
||||
msgid "Company"
|
||||
msgstr "บริษัท"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "ข้อมูลบริษัท"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "ติดต่อเรา"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "คุณวางแผนจะใช้ Fluxer กับชุมช
|
||||
msgid "Hungarian"
|
||||
msgstr "ฮังการี"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "ข้อมูลระบุตำแหน่งจาก IP โดย "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "เปิด Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "เปิดในเบราว์เซอร์"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "พร้อมอัปเกรดหรือยัง?"
|
||||
msgid "Register now"
|
||||
msgstr "ลงทะเบียนตอนนี้"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "รายงานบั๊ก"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "ผลการค้นหาสำหรับ: "
|
||||
msgid "Search through message history"
|
||||
msgstr "ค้นหาในประวัติข้อความ"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "ซาวด์บอร์ด"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "ซอร์สโค้ด"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "ตอนนี้ยังไม่ใช่ตัวแทนเดส
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "หน้านี้ไม่มีอยู่ แต่ยังมีอีกหลายอย่างให้สำรวจ"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "ผลิตภัณฑ์นี้รวมถึงข้อมูล GeoLite2 ที่สร้างโดย MaxMind ซึ่งสามารถดาวน์โหลดได้จาก "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "เธรดและฟอรั่ม"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "ผลลัพธ์สำหรับ"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "เหลืออีก {0} จาก {1} สิทธิ์"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (บริษัทจำกัดในสวีเดน: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Mavi (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Kompakt mod ve görüntü seçenekleri"
|
||||
msgid "Company"
|
||||
msgstr "Şirket"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Şirket Bilgileri"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Bağlan"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Fluxer'ı topluluğunla nasıl kullanmayı planlıyorsun?"
|
||||
msgid "Hungarian"
|
||||
msgstr "Macarca"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP konum verileri sağlayıcısı: "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Fluxer'ı aç"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Tarayıcıda aç"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Yükseltmeye hazır mısın?"
|
||||
msgid "Register now"
|
||||
msgstr "Hemen kayıt ol"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Hata bildir"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Şunun için arama sonuçları: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Mesaj geçmişinde ara"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Ses panosu"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Kaynak kodu"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Bu henüz masaüstü uygulamasının tam bir yerine geçmiyor; mobil soh
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Bu sayfa yok. Ama keşfedecek daha pek çok şey var."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Bu ürün, MaxMind tarafından oluşturulan GeoLite2 Verilerini içermektedir, şu adresten temin edilebilir."
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Thread'ler ve forumlar"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "sonuç"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "{1} kontenjandan {0} kaldı"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (İsveç limited şirketi: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Синій (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Компактний режим і параметри відображе
|
||||
msgid "Company"
|
||||
msgstr "Компанія"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Інформація про компанію"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Зв’язок"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Як ви плануєте використовувати Fluxer зі с
|
||||
msgid "Hungarian"
|
||||
msgstr "Угорська"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Дані геолокації за IP від "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Відкрити Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Відкрити в браузері"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Готові оновитися?"
|
||||
msgid "Register now"
|
||||
msgstr "Зареєструватися"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Повідомити про баг"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Результати пошуку для: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Пошук в історії повідомлень"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Саундборд"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Вихідний код"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Це ще не повна заміна десктопного засто
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Цієї сторінки не існує. Але попереду ще багато цікавого."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Цей продукт містить дані GeoLite2, створені компанією MaxMind, доступні з "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Гілки та форуми"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "результати для"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Залишилося {0} із {1} місць"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (шведська компанія з обмеженою відповідальністю: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "Xanh dương (Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "Chế độ gọn và tùy chọn hiển thị"
|
||||
msgid "Company"
|
||||
msgstr "Công ty"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "Thông tin công ty"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "Kết nối"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "Bạn dự định dùng Fluxer với cộng đồng của mình như th
|
||||
msgid "Hungarian"
|
||||
msgstr "Tiếng Hungary"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "Dữ liệu định vị IP bởi "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "Mở Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "Mở trong trình duyệt"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "Sẵn sàng nâng cấp?"
|
||||
msgid "Register now"
|
||||
msgstr "Đăng ký ngay"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "Báo lỗi"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "Kết quả tìm kiếm cho: "
|
||||
msgid "Search through message history"
|
||||
msgstr "Tìm trong lịch sử tin nhắn"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "Bảng âm thanh"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "Mã nguồn"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "Hiện đây vẫn chưa thể thay thế hoàn toàn ứng dụng máy
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "Trang này không tồn tại. Nhưng vẫn còn rất nhiều thứ để khám phá."
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "Sản phẩm này bao gồm Dữ liệu GeoLite2 được tạo bởi MaxMind, có sẵn từ "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "Chuỗi thảo luận và diễn đàn"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "kết quả cho"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "Còn {0}/{1} suất"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB (công ty TNHH tại Thụy Điển: 559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "蓝色(Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "紧凑模式与显示选项"
|
||||
msgid "Company"
|
||||
msgstr "公司"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "公司信息"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "联系"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "你计划如何在社区中使用 Fluxer"
|
||||
msgid "Hungarian"
|
||||
msgstr "匈牙利语"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP 地理定位数据由 "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "打开 Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "在浏览器中打开"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "准备升级了吗?"
|
||||
msgid "Register now"
|
||||
msgstr "立即注册"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "报告 Bug"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "搜索结果: "
|
||||
msgid "Search through message history"
|
||||
msgstr "搜索消息记录"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "音效面板"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "源代码"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "这还不能完全替代桌面端应用,移动聊天应用常见的一
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "这个页面不存在,但还有更多内容值得探索。"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "本产品包含由 MaxMind 创建的 GeoLite2 数据,可从 "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "线程与论坛"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "条结果,关键词:"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "还剩 {0}/{1} 个名额"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB(瑞典有限责任公司:559537-3993)"
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ msgid "Blue (Da Ba Dee)"
|
||||
msgstr "藍色(Da Ba Dee)"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:157
|
||||
#: src/fluxer_marketing/components/navigation.gleam:449
|
||||
#: src/fluxer_marketing/components/navigation.gleam:458
|
||||
msgid "Bluesky"
|
||||
msgstr "Bluesky"
|
||||
|
||||
@@ -363,15 +363,15 @@ msgstr "精簡模式與顯示選項"
|
||||
msgid "Company"
|
||||
msgstr "公司"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/footer.gleam:282
|
||||
#: src/fluxer_marketing/components/navigation.gleam:376
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:41
|
||||
#: src/fluxer_marketing/pages/company_page.gleam:44
|
||||
msgid "Company Information"
|
||||
msgstr "公司資訊"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:278
|
||||
#: src/fluxer_marketing/components/navigation.gleam:426
|
||||
#: src/fluxer_marketing/components/footer.gleam:289
|
||||
#: src/fluxer_marketing/components/navigation.gleam:435
|
||||
msgid "Connect"
|
||||
msgstr "連結"
|
||||
|
||||
@@ -1010,10 +1010,6 @@ msgstr "你打算如何在社群中使用 Fluxer"
|
||||
msgid "Hungarian"
|
||||
msgstr "匈牙利語"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:329
|
||||
msgid "IP geolocation data by "
|
||||
msgstr "IP 地理定位資料提供: "
|
||||
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:223
|
||||
#: src/fluxer_marketing/pages/help_article_page.gleam:365
|
||||
msgid "If you couldn't find what you're looking for, our support team is here to help."
|
||||
@@ -1267,7 +1263,7 @@ msgid "Open Fluxer"
|
||||
msgstr "開啟 Fluxer"
|
||||
|
||||
#: src/fluxer_marketing/components/hero.gleam:82
|
||||
#: src/fluxer_marketing/components/navigation.gleam:473
|
||||
#: src/fluxer_marketing/components/navigation.gleam:482
|
||||
msgid "Open in Browser"
|
||||
msgstr "在瀏覽器中開啟"
|
||||
|
||||
@@ -1501,7 +1497,7 @@ msgstr "準備升級了嗎?"
|
||||
msgid "Register now"
|
||||
msgstr "立即註冊"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:311
|
||||
#: src/fluxer_marketing/components/footer.gleam:322
|
||||
msgid "Report a bug"
|
||||
msgstr "回報錯誤"
|
||||
|
||||
@@ -1609,7 +1605,9 @@ msgstr "搜尋結果: "
|
||||
msgid "Search through message history"
|
||||
msgstr "搜尋訊息歷史紀錄"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:271
|
||||
#: src/fluxer_marketing/components/get_involved_section.gleam:194
|
||||
#: src/fluxer_marketing/components/navigation.gleam:424
|
||||
#: src/fluxer_marketing/pages/careers_page.gleam:156
|
||||
#: src/fluxer_marketing/pages/security_page.gleam:41
|
||||
msgid "Security Bug Bounty"
|
||||
@@ -1680,7 +1678,7 @@ msgid "Soundboard"
|
||||
msgstr "音效板"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:143
|
||||
#: src/fluxer_marketing/components/navigation.gleam:438
|
||||
#: src/fluxer_marketing/components/navigation.gleam:447
|
||||
msgid "Source Code"
|
||||
msgstr "原始碼"
|
||||
|
||||
@@ -1781,6 +1779,10 @@ msgstr "目前還無法完全取代桌面版,行動聊天 App 常見的一些
|
||||
msgid "This page doesn't exist. But there's plenty more to explore."
|
||||
msgstr "這個頁面不存在,不過還有很多內容等你探索。"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:342
|
||||
msgid "This product includes GeoLite2 Data created by MaxMind, available from "
|
||||
msgstr "此產品包含由 MaxMind 創建的 GeoLite2 數據,可從 "
|
||||
|
||||
#: src/fluxer_marketing/components/coming_features.gleam:37
|
||||
msgid "Threads and forums"
|
||||
msgstr "討論串與論壇"
|
||||
@@ -2066,7 +2068,7 @@ msgstr "筆結果,關於"
|
||||
msgid "{0} of {1} slots left"
|
||||
msgstr "剩下 {0}/{1} 個名額"
|
||||
|
||||
#: src/fluxer_marketing/components/footer.gleam:325
|
||||
#: src/fluxer_marketing/components/footer.gleam:336
|
||||
msgid "© Fluxer Platform AB (Swedish limited liability company: 559537-3993)"
|
||||
msgstr "© Fluxer Platform AB(瑞典有限公司:559537-3993)"
|
||||
|
||||
|
||||
@@ -83,7 +83,11 @@ fn handle_request(
|
||||
let locale = get_request_locale(req)
|
||||
|
||||
let base_url = cfg.marketing_endpoint <> cfg.base_path
|
||||
let country_code = geoip.country_code(req, cfg.geoip_host)
|
||||
let country_code =
|
||||
geoip.country_code(
|
||||
req,
|
||||
geoip.Settings(api_host: cfg.api_host, rpc_secret: cfg.gateway_rpc_secret),
|
||||
)
|
||||
|
||||
let user_agent = case request.get_header(req, "user-agent") {
|
||||
Ok(ua) -> ua
|
||||
@@ -107,7 +111,6 @@ fn handle_request(
|
||||
base_path: cfg.base_path,
|
||||
platform: platform,
|
||||
architecture: architecture,
|
||||
geoip_host: cfg.geoip_host,
|
||||
release_channel: cfg.release_channel,
|
||||
visionary_slots: visionary_slots.current(slots_cache),
|
||||
metrics_endpoint: cfg.metrics_endpoint,
|
||||
|
||||
@@ -337,16 +337,20 @@ pub fn render(ctx: Context) -> Element(a) {
|
||||
)),
|
||||
]),
|
||||
html.p([attribute.class("body-sm text-white/80")], [
|
||||
html.text(g_(i18n_ctx, "IP geolocation data by ")),
|
||||
html.text(g_(
|
||||
i18n_ctx,
|
||||
"This product includes GeoLite2 Data created by MaxMind, available from ",
|
||||
)),
|
||||
html.a(
|
||||
[
|
||||
attribute.href("https://ipinfo.io"),
|
||||
attribute.href("https://www.maxmind.com"),
|
||||
attribute.target("_blank"),
|
||||
attribute.rel("noopener noreferrer"),
|
||||
attribute.class("hover:underline"),
|
||||
],
|
||||
[html.text("IPinfo")],
|
||||
[html.text("MaxMind")],
|
||||
),
|
||||
html.text("."),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -32,7 +32,6 @@ pub type Config {
|
||||
marketing_endpoint: String,
|
||||
port: Int,
|
||||
base_path: String,
|
||||
geoip_host: String,
|
||||
build_timestamp: String,
|
||||
release_channel: String,
|
||||
gateway_rpc_secret: String,
|
||||
@@ -101,7 +100,6 @@ pub fn load_config() -> Result(Config, String) {
|
||||
"FLUXER_MARKETING_ENDPOINT",
|
||||
))
|
||||
use base_path_raw <- result.try(required_env("FLUXER_PATH_MARKETING"))
|
||||
use geoip_host <- result.try(required_env("GEOIP_HOST"))
|
||||
use port <- result.try(required_int_env("FLUXER_MARKETING_PORT"))
|
||||
use release_channel <- result.try(required_env("RELEASE_CHANNEL"))
|
||||
use gateway_rpc_secret <- result.try(required_env("GATEWAY_RPC_SECRET"))
|
||||
@@ -125,7 +123,6 @@ pub fn load_config() -> Result(Config, String) {
|
||||
marketing_endpoint: marketing_endpoint,
|
||||
port: port,
|
||||
base_path: base_path,
|
||||
geoip_host: geoip_host,
|
||||
build_timestamp: optional_env("BUILD_TIMESTAMP") |> option.unwrap(""),
|
||||
release_channel: release_channel,
|
||||
gateway_rpc_secret: gateway_rpc_secret,
|
||||
|
||||
@@ -15,180 +15,180 @@
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import gleam/bit_array
|
||||
import gleam/dynamic/decode
|
||||
import gleam/http
|
||||
import gleam/http/request
|
||||
import gleam/httpc
|
||||
import gleam/int
|
||||
import gleam/json
|
||||
import gleam/list
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
import wisp
|
||||
|
||||
const default_cc = "US"
|
||||
|
||||
pub fn country_code(req: wisp.Request, geoip_host: String) -> String {
|
||||
let get_header = fn(name) { request.get_header(req, name) }
|
||||
country_code_core(get_header, geoip_host, fetch_country_code_http)
|
||||
pub type Settings {
|
||||
Settings(api_host: String, rpc_secret: String)
|
||||
}
|
||||
|
||||
pub fn country_code_core(
|
||||
get_header: fn(String) -> Result(String, Nil),
|
||||
geoip_host: String,
|
||||
fetch_country: fn(String) -> Result(String, Nil),
|
||||
) -> String {
|
||||
case geoip_host {
|
||||
const default_cc = "US"
|
||||
|
||||
const log_prefix = "[geoip]"
|
||||
|
||||
pub fn country_code(req: wisp.Request, settings: Settings) -> String {
|
||||
case extract_client_ip(req) {
|
||||
"" -> default_cc
|
||||
_ -> {
|
||||
case extract_client_ip_from(get_header) {
|
||||
"" -> default_cc
|
||||
ip -> {
|
||||
let url =
|
||||
"http://" <> geoip_host <> "/lookup?ip=" <> percent_encode_ip(ip)
|
||||
case fetch_country(url) {
|
||||
Ok(body) -> {
|
||||
let cc = string.uppercase(string.trim(body))
|
||||
case is_valid_country_code(cc) {
|
||||
True -> cc
|
||||
False -> default_cc
|
||||
}
|
||||
}
|
||||
Error(_) -> default_cc
|
||||
}
|
||||
ip ->
|
||||
case fetch_country_code(settings, ip) {
|
||||
Ok(code) -> code
|
||||
Error(_) -> default_cc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_country_code(settings: Settings, ip: String) -> Result(String, Nil) {
|
||||
case rpc_url(settings.api_host) {
|
||||
"" -> {
|
||||
log_missing_api_host(settings.api_host)
|
||||
Error(Nil)
|
||||
}
|
||||
url -> {
|
||||
let body =
|
||||
json.object([
|
||||
#("type", json.string("geoip_lookup")),
|
||||
#("ip", json.string(ip)),
|
||||
])
|
||||
|> json.to_string
|
||||
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req =
|
||||
req
|
||||
|> request.set_method(http.Post)
|
||||
|> request.prepend_header("content-type", "application/json")
|
||||
|> request.prepend_header(
|
||||
"Authorization",
|
||||
"Bearer " <> settings.rpc_secret,
|
||||
)
|
||||
|> request.set_body(body)
|
||||
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status >= 200 && resp.status < 300 ->
|
||||
decode_country_code(resp.body)
|
||||
Ok(resp) -> {
|
||||
log_rpc_status(settings.api_host, resp.status, resp.body)
|
||||
Error(Nil)
|
||||
}
|
||||
Error(error) -> {
|
||||
log_rpc_error(settings.api_host, string.inspect(error))
|
||||
Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_country_code_http(url: String) -> Result(String, Nil) {
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = request.prepend_header(req, "accept", "text/plain")
|
||||
case httpc.send(req) {
|
||||
Ok(resp) if resp.status >= 200 && resp.status < 300 -> Ok(resp.body)
|
||||
_ -> Error(Nil)
|
||||
fn decode_country_code(body: String) -> Result(String, Nil) {
|
||||
let response_decoder = {
|
||||
use data <- decode.field("data", {
|
||||
use code <- decode.field("country_code", decode.string)
|
||||
decode.success(code)
|
||||
})
|
||||
decode.success(data)
|
||||
}
|
||||
|
||||
case json.parse(from: body, using: response_decoder) {
|
||||
Ok(code) -> Ok(string.uppercase(string.trim(code)))
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_client_ip(req: wisp.Request) -> String {
|
||||
extract_client_ip_from(fn(name) { request.get_header(req, name) })
|
||||
}
|
||||
|
||||
pub fn extract_client_ip_from(
|
||||
get_header: fn(String) -> Result(String, Nil),
|
||||
) -> String {
|
||||
case get_header("x-forwarded-for") {
|
||||
Ok(xff) -> {
|
||||
case request.get_header(req, "x-forwarded-for") {
|
||||
Ok(xff) ->
|
||||
xff
|
||||
|> string.split(",")
|
||||
|> list.first
|
||||
|> result.unwrap("")
|
||||
|> string.trim
|
||||
|> strip_brackets
|
||||
|> validate_ip
|
||||
}
|
||||
Error(_) -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_ip(s: String) -> String {
|
||||
case string.contains(s, ".") || string.contains(s, ":") {
|
||||
True -> s
|
||||
False -> ""
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_brackets(ip: String) -> String {
|
||||
let len = string.length(ip)
|
||||
let has_brackets =
|
||||
case
|
||||
len >= 2
|
||||
&& string.first(ip) == Ok("[")
|
||||
&& string.slice(ip, len - 1, 1) == "]"
|
||||
|
||||
case has_brackets {
|
||||
{
|
||||
True -> string.slice(ip, 1, len - 2)
|
||||
False -> ip
|
||||
}
|
||||
}
|
||||
|
||||
pub fn percent_encode_ip(s: String) -> String {
|
||||
s
|
||||
|> string.replace("%", "%25")
|
||||
|> string.replace(":", "%3A")
|
||||
|> string.replace(" ", "%20")
|
||||
fn log_missing_api_host(host: String) -> Nil {
|
||||
wisp.log_warning(
|
||||
string.concat([log_prefix, " missing api_host (", host, ")"]),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_ascii_upper_alpha2(s: String) -> Bool {
|
||||
case string.byte_size(s) == 2 {
|
||||
False -> False
|
||||
True ->
|
||||
case string.to_graphemes(s) {
|
||||
[a, b] -> is_uppercase_letter(a) && is_uppercase_letter(b)
|
||||
_ -> False
|
||||
fn log_rpc_status(api_host: String, status: Int, body: String) -> Nil {
|
||||
wisp.log_warning(
|
||||
string.concat([
|
||||
log_prefix,
|
||||
" rpc returned status ",
|
||||
int.to_string(status),
|
||||
" from ",
|
||||
host_display(api_host),
|
||||
": ",
|
||||
response_snippet(body),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn log_rpc_error(api_host: String, message: String) -> Nil {
|
||||
wisp.log_warning(
|
||||
string.concat([
|
||||
log_prefix,
|
||||
" rpc request to ",
|
||||
host_display(api_host),
|
||||
" failed: ",
|
||||
message,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_display(api_host: String) -> String {
|
||||
case string.contains(api_host, "://") {
|
||||
True -> api_host
|
||||
False -> "http://" <> api_host
|
||||
}
|
||||
}
|
||||
|
||||
fn rpc_url(api_host: String) -> String {
|
||||
let host = string.trim(api_host)
|
||||
case host {
|
||||
"" -> ""
|
||||
_ -> {
|
||||
let base = case string.contains(host, "://") {
|
||||
True -> host
|
||||
False -> "http://" <> host
|
||||
}
|
||||
|
||||
let normalized = case string.ends_with(base, "/") {
|
||||
True -> string.slice(base, 0, string.length(base) - 1)
|
||||
False -> base
|
||||
}
|
||||
|
||||
normalized <> "/_rpc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_country_code(s: String) -> Bool {
|
||||
is_ascii_upper_alpha2(s)
|
||||
}
|
||||
|
||||
fn is_uppercase_letter(g: String) -> Bool {
|
||||
case bit_array.from_string(g) {
|
||||
<<c:8>> -> c >= 65 && c <= 90
|
||||
_ -> False
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_info(req: wisp.Request, geoip_host: String) -> String {
|
||||
let xff =
|
||||
request.get_header(req, "x-forwarded-for") |> result.unwrap("(not set)")
|
||||
let ip = extract_client_ip(req)
|
||||
let host_display = case geoip_host {
|
||||
"" -> "(not set)"
|
||||
h -> h
|
||||
}
|
||||
|
||||
let url = case ip {
|
||||
"" -> "(empty IP - no URL)"
|
||||
_ -> "http://" <> host_display <> "/lookup?ip=" <> percent_encode_ip(ip)
|
||||
}
|
||||
|
||||
let response = case ip {
|
||||
"" -> "(empty IP - no request)"
|
||||
_ -> fetch_geoip_debug(url)
|
||||
}
|
||||
|
||||
json.object([
|
||||
#("x_forwarded_for_header", json.string(xff)),
|
||||
#("extracted_ip", json.string(ip)),
|
||||
#(
|
||||
"stripped_brackets",
|
||||
json.string(case ip {
|
||||
"" -> "(empty)"
|
||||
i -> strip_brackets(i)
|
||||
}),
|
||||
),
|
||||
#(
|
||||
"percent_encoded",
|
||||
json.string(case ip {
|
||||
"" -> "(empty)"
|
||||
i -> percent_encode_ip(i)
|
||||
}),
|
||||
),
|
||||
#("geoip_host", json.string(host_display)),
|
||||
#("geoip_url", json.string(url)),
|
||||
#("geoip_response", json.string(response)),
|
||||
#("final_country_code", json.string(country_code(req, geoip_host))),
|
||||
])
|
||||
|> json.to_string
|
||||
}
|
||||
|
||||
fn fetch_geoip_debug(url: String) -> String {
|
||||
let assert Ok(req) = request.to(url)
|
||||
let req = request.prepend_header(req, "accept", "text/plain")
|
||||
case httpc.send(req) {
|
||||
Ok(resp) ->
|
||||
"Status: " <> string.inspect(resp.status) <> ", Body: " <> resp.body
|
||||
Error(_) -> "(request failed)"
|
||||
fn response_snippet(body: String) -> String {
|
||||
let len = string.length(body)
|
||||
case len <= 256 {
|
||||
True -> body
|
||||
False -> string.slice(body, 0, 256) <> "..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_marketing/badge_proxy
|
||||
import fluxer_marketing/geoip
|
||||
import fluxer_marketing/help_center
|
||||
import fluxer_marketing/locale
|
||||
import fluxer_marketing/pages/careers_page
|
||||
@@ -50,8 +49,6 @@ pub fn handle_request(req: Request, ctx: Context) -> Response {
|
||||
case wisp.path_segments(req) {
|
||||
[] -> home_page.render(req, ctx)
|
||||
["_locale"] -> handle_locale_change(req, ctx)
|
||||
["_debug", "geoip"] -> handle_geoip_debug(req, ctx)
|
||||
|
||||
["api", "badges", "product-hunt"] ->
|
||||
badge_proxy.product_hunt(ctx.badge_featured_cache)
|
||||
|
||||
@@ -378,10 +375,3 @@ pub fn update_locale_in_path(path: String, new_locale: locale.Locale) -> String
|
||||
_ -> path
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_geoip_debug(req: Request, ctx: Context) -> Response {
|
||||
let json_body = geoip.debug_info(req, ctx.geoip_host)
|
||||
wisp.response(200)
|
||||
|> wisp.set_header("content-type", "application/json; charset=utf-8")
|
||||
|> wisp.string_body(json_body)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ pub type Context {
|
||||
base_path: String,
|
||||
platform: Platform,
|
||||
architecture: Architecture,
|
||||
geoip_host: String,
|
||||
release_channel: String,
|
||||
visionary_slots: VisionarySlots,
|
||||
metrics_endpoint: Option(String),
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//// Copyright (C) 2026 Fluxer Contributors
|
||||
////
|
||||
//// This file is part of Fluxer.
|
||||
////
|
||||
//// Fluxer is free software: you can redistribute it and/or modify
|
||||
//// it under the terms of the GNU Affero General Public License as published by
|
||||
//// the Free Software Foundation, either version 3 of the License, or
|
||||
//// (at your option) any later version.
|
||||
////
|
||||
//// Fluxer is distributed in the hope that it will be useful,
|
||||
//// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
//// GNU Affero General Public License for more details.
|
||||
////
|
||||
//// You should have received a copy of the GNU Affero General Public License
|
||||
//// along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import fluxer_marketing/geoip
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
import gleeunit
|
||||
import gleeunit/should
|
||||
|
||||
pub fn main() {
|
||||
gleeunit.main()
|
||||
}
|
||||
|
||||
fn gh(headers: List(#(String, String))) -> fn(String) -> Result(String, Nil) {
|
||||
fn(name) {
|
||||
let lname = string.lowercase(name)
|
||||
|
||||
let found =
|
||||
list.find(headers, fn(pair) {
|
||||
let #(k, _) = pair
|
||||
string.lowercase(k) == lname
|
||||
})
|
||||
|
||||
case found {
|
||||
Ok(#(_, v)) -> Ok(v)
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gh_empty() -> fn(String) -> Result(String, Nil) {
|
||||
fn(_) { Error(Nil) }
|
||||
}
|
||||
|
||||
fn http_ok(body: String) -> fn(String) -> Result(String, Nil) {
|
||||
fn(_url) { Ok(body) }
|
||||
}
|
||||
|
||||
fn http_err() -> fn(String) -> Result(String, Nil) {
|
||||
fn(_url) { Error(Nil) }
|
||||
}
|
||||
|
||||
pub fn no_host_defaults_to_us_test() {
|
||||
let cc = geoip.country_code_core(gh_empty(), "", http_ok("SE"))
|
||||
cc |> should.equal("US")
|
||||
}
|
||||
|
||||
pub fn missing_ip_defaults_to_us_test() {
|
||||
let cc = geoip.country_code_core(gh_empty(), "geoip:8080", http_ok("SE"))
|
||||
cc |> should.equal("US")
|
||||
}
|
||||
|
||||
pub fn invalid_ip_defaults_to_us_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "not_an_ip")]),
|
||||
"geoip:8080",
|
||||
http_ok("SE"),
|
||||
)
|
||||
cc |> should.equal("US")
|
||||
}
|
||||
|
||||
pub fn ipv4_success_uppercases_and_validates_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "8.8.8.8")]),
|
||||
"geoip:8080",
|
||||
http_ok("se"),
|
||||
)
|
||||
cc |> should.equal("SE")
|
||||
}
|
||||
|
||||
pub fn ipv6_bracketed_success_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "[2001:db8::1]")]),
|
||||
"geoip:8080",
|
||||
http_ok("de"),
|
||||
)
|
||||
cc |> should.equal("DE")
|
||||
}
|
||||
|
||||
pub fn multiple_xff_uses_first_token_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "1.1.1.1, 8.8.8.8")]),
|
||||
"geoip:8080",
|
||||
http_ok("gb"),
|
||||
)
|
||||
cc |> should.equal("GB")
|
||||
}
|
||||
|
||||
pub fn http_error_falls_back_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "8.8.4.4")]),
|
||||
"geoip:8080",
|
||||
http_err(),
|
||||
)
|
||||
cc |> should.equal("US")
|
||||
}
|
||||
|
||||
pub fn invalid_body_falls_back_test() {
|
||||
let cc =
|
||||
geoip.country_code_core(
|
||||
gh([#("x-forwarded-for", "8.8.4.4")]),
|
||||
"geoip:8080",
|
||||
http_ok("USA"),
|
||||
)
|
||||
cc |> should.equal("US")
|
||||
}
|
||||
|
||||
pub fn strip_brackets_helper_test() {
|
||||
geoip.strip_brackets("[::1]") |> should.equal("::1")
|
||||
geoip.strip_brackets("127.0.0.1") |> should.equal("127.0.0.1")
|
||||
}
|
||||
|
||||
pub fn percent_encode_ip_helper_test() {
|
||||
geoip.percent_encode_ip("2001:db8::1") |> should.equal("2001%3Adb8%3A%3A1")
|
||||
geoip.percent_encode_ip("100% legit") |> should.equal("100%25%20legit")
|
||||
}
|
||||
|
||||
pub fn is_ascii_upper_alpha2_helper_test() {
|
||||
geoip.is_ascii_upper_alpha2("US") |> should.equal(True)
|
||||
geoip.is_ascii_upper_alpha2("uS") |> should.equal(False)
|
||||
geoip.is_ascii_upper_alpha2("USA") |> should.equal(False)
|
||||
geoip.is_ascii_upper_alpha2("") |> should.equal(False)
|
||||
}
|
||||
131
justfile
131
justfile
@@ -1,108 +1,64 @@
|
||||
# Fluxer Development Justfile
|
||||
# Uses the dev CLI (Go) as a compose wrapper
|
||||
env_file := "dev/.env"
|
||||
compose_file := "dev/compose.yaml"
|
||||
data_compose := "dev/compose.data.yaml"
|
||||
network_name := "fluxer-shared"
|
||||
compose_base := "docker compose --env-file " + env_file + " -f " + compose_file
|
||||
livekit_template := "dev/templates/livekit.yaml"
|
||||
|
||||
# Dev CLI command
|
||||
devctl := "go run ./dev"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker Compose: Service Management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Start all or selected services in the background
|
||||
# Usage:
|
||||
# just up # all services
|
||||
# just up api gateway # specific services
|
||||
up *SERVICES:
|
||||
{{devctl}} ensure-network
|
||||
{{devctl}} up {{SERVICES}}
|
||||
just ensure-network
|
||||
{{compose_base}} up -d {{SERVICES}}
|
||||
|
||||
# Start all or selected services and watch for changes
|
||||
# Usage:
|
||||
# just watch
|
||||
# just watch api gateway
|
||||
watch *SERVICES:
|
||||
{{devctl}} ensure-network
|
||||
docker compose --env-file dev/.env -f dev/compose.yaml watch {{SERVICES}}
|
||||
just ensure-network
|
||||
{{compose_base}} watch {{SERVICES}}
|
||||
|
||||
# Stop and remove containers (preserves volumes)
|
||||
# Usage:
|
||||
# just down
|
||||
down:
|
||||
{{devctl}} down
|
||||
{{compose_base}} down
|
||||
|
||||
# Stop and remove containers including volumes
|
||||
# Usage:
|
||||
# just nuke
|
||||
nuke:
|
||||
{{devctl}} down --volumes
|
||||
{{compose_base}} down -v
|
||||
|
||||
# Restart all or selected services
|
||||
# Usage:
|
||||
# just restart
|
||||
# just restart api gateway
|
||||
restart *SERVICES:
|
||||
{{devctl}} restart {{SERVICES}}
|
||||
{{compose_base}} restart {{SERVICES}}
|
||||
|
||||
# Show logs for all or selected services
|
||||
# Usage:
|
||||
# just logs
|
||||
# just logs api
|
||||
# just logs api gateway
|
||||
logs *SERVICES:
|
||||
{{devctl}} logs {{SERVICES}}
|
||||
{{compose_base}} logs -f --tail 100 {{SERVICES}}
|
||||
|
||||
# List running containers
|
||||
# Usage:
|
||||
# just ps
|
||||
ps:
|
||||
{{devctl}} ps
|
||||
{{compose_base}} ps
|
||||
|
||||
# Open a shell in a service container
|
||||
# Usage:
|
||||
# just sh api
|
||||
sh SERVICE:
|
||||
{{devctl}} sh {{SERVICE}}
|
||||
sh SERVICE shell="sh":
|
||||
{{compose_base}} exec {{SERVICE}} {{shell}}
|
||||
|
||||
# Execute a command in a service container
|
||||
# Usage:
|
||||
# just exec api "env | sort"
|
||||
exec SERVICE CMD:
|
||||
{{devctl}} exec {{SERVICE}} sh -c "{{CMD}}"
|
||||
{{compose_base}} exec {{SERVICE}} sh -c "{{CMD}}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration & Setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sync LiveKit configuration from environment variables
|
||||
# Usage:
|
||||
# just livekit-sync
|
||||
livekit-sync:
|
||||
{{devctl}} livekit-sync
|
||||
set -euo pipefail
|
||||
if [ ! -f {{env_file}} ]; then
|
||||
echo "{{env_file}} missing"
|
||||
exit 1
|
||||
fi
|
||||
node --env-file {{env_file}} scripts/just/livekit-sync.js --output dev/livekit.yaml
|
||||
|
||||
# Download GeoIP database
|
||||
# Usage:
|
||||
# just geoip-download
|
||||
# just geoip-download TOKEN=xxx
|
||||
geoip-download TOKEN='':
|
||||
if [ "{{TOKEN}}" = "" ]; then {{devctl}} geoip-download; else {{devctl}} geoip-download --token {{TOKEN}}; fi
|
||||
|
||||
# Ensure Docker network exists
|
||||
# Usage:
|
||||
# just ensure-network
|
||||
ensure-network:
|
||||
{{devctl}} ensure-network
|
||||
set -euo pipefail
|
||||
docker network inspect {{network_name}} >/dev/null 2>&1 || docker network create {{network_name}}
|
||||
|
||||
# Bootstrap development environment
|
||||
# Usage:
|
||||
# just bootstrap
|
||||
bootstrap:
|
||||
just ensure-network
|
||||
just livekit-sync
|
||||
just geoip-download
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cassandra Migrations
|
||||
# ---------------------------------------------------------------------------
|
||||
setup:
|
||||
set -euo pipefail
|
||||
just ensure-network
|
||||
if [ ! -f dev/.env ]; then
|
||||
cp dev/.env.example dev/.env
|
||||
fi
|
||||
if [ ! -f dev/livekit.yaml ]; then
|
||||
cp {{livekit_template}} dev/livekit.yaml
|
||||
fi
|
||||
|
||||
mig name:
|
||||
@cargo run --release --quiet --manifest-path scripts/cassandra-migrate/Cargo.toml -- create "{{name}}"
|
||||
@@ -116,38 +72,21 @@ mig-up host="localhost" user="cassandra" pass="cassandra":
|
||||
mig-status host="localhost" user="cassandra" pass="cassandra":
|
||||
@cargo run --release --quiet --manifest-path scripts/cassandra-migrate/Cargo.toml -- --host "{{host}}" --username "{{user}}" --password "{{pass}}" status
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Run license enforcer
|
||||
lic:
|
||||
@cargo run --release --quiet --manifest-path scripts/license-enforcer/Cargo.toml
|
||||
|
||||
# Generate snowflake IDs
|
||||
snow count="1":
|
||||
@cargo run --release --quiet --manifest-path scripts/snowflake-generator/Cargo.toml -- --count {{count}}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Spin up the full integration stack, run the Go tests, then tear everything down
|
||||
integration-tests:
|
||||
set -euo pipefail
|
||||
trap 'docker compose -f tests/integration/compose.yaml down' EXIT
|
||||
docker compose -f tests/integration/compose.yaml up --build --abort-on-container-exit integration-tests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Go Tooling & QA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Install pinned Go tooling (staticcheck, golangci-lint) with Go 1.25.5
|
||||
go-tools-install:
|
||||
GOTOOLCHAIN=go1.25.5 go install honnef.co/go/tools/cmd/staticcheck@2025.1.1
|
||||
GOTOOLCHAIN=go1.25.5 go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0
|
||||
|
||||
# Run formatting, tests, and linters for integration tests
|
||||
go-integration-check:
|
||||
gofmt -w tests/integration
|
||||
go test ./tests/integration/...
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rustlang/rust:nightly as builder
|
||||
FROM rustlang/rust:nightly AS builder
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
|
||||
51
scripts/just/livekit-sync.js
Executable file
51
scripts/just/livekit-sync.js
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const outputIdx = args.indexOf('--output');
|
||||
const outputArg = outputIdx >= 0 && args[outputIdx + 1] ? args[outputIdx + 1] : 'dev/livekit.yaml';
|
||||
|
||||
const voiceEnabled = (process.env.VOICE_ENABLED || '').trim().toLowerCase() === 'true';
|
||||
if (!voiceEnabled) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const apiKey = (process.env.LIVEKIT_API_KEY || '').trim();
|
||||
const apiSecret = (process.env.LIVEKIT_API_SECRET || '').trim();
|
||||
const webhookUrl = (process.env.LIVEKIT_WEBHOOK_URL || '').trim();
|
||||
if (!apiKey || !apiSecret || !webhookUrl) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const redisUrl = (process.env.REDIS_URL || '').trim();
|
||||
const redisAddr = redisUrl.replace(/^redis:\/\//, '') || 'redis:6379';
|
||||
|
||||
const yaml = `port: 7880
|
||||
|
||||
redis:
|
||||
address: "${redisAddr}"
|
||||
db: 0
|
||||
|
||||
keys:
|
||||
"${apiKey}": "${apiSecret}"
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: "${apiKey}"
|
||||
urls:
|
||||
- "${webhookUrl}"
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
|
||||
`;
|
||||
const outputPath = path.resolve(outputArg);
|
||||
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
|
||||
fs.writeFileSync(outputPath, yaml, {encoding: 'utf-8', mode: 0o600});
|
||||
Reference in New Issue
Block a user