refactor(geoip): reconcile geoip system (#31)

This commit is contained in:
hampus-fluxer
2026-01-05 23:19:05 +01:00
committed by GitHub
parent 5d047b2856
commit 2e007b5076
86 changed files with 982 additions and 2648 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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=

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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 ""

View File

@@ -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,

View File

@@ -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

View File

@@ -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([], [

View File

@@ -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);

View File

@@ -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,
},

View File

@@ -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;
}

View File

@@ -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,
};
}),
};
}
}

View File

@@ -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>;

View File

@@ -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');

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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>;

View File

@@ -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,
});
});
};

View File

@@ -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,
};
}

View File

@@ -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({

View File

@@ -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');
}

View File

@@ -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';

View File

@@ -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');
});
});

View File

@@ -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
);
}

View 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,
};
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE fluxer.auth_sessions ADD client_user_agent text;
ALTER TABLE fluxer.auth_sessions ADD client_is_desktop boolean;

View File

@@ -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"]

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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))
}

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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 daffichage"
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 lentreprise"
#: 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 "Sinscrire 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 lhistorique 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 nest pas encore un remplacement complet de lapp de bureau, et c
msgid "This page doesn't exist. But there's plenty more to explore."
msgstr "Cette page nexiste 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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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 sullazienda"
#: 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 lupgrade?"
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 dellapp 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)"

View File

@@ -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"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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 ""

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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)"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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,

View File

@@ -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("."),
]),
]),
]),

View File

@@ -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,

View File

@@ -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) <> "..."
}
}

View File

@@ -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)
}

View File

@@ -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),

View File

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

@@ -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/...

View File

@@ -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
View 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});