This commit is contained in:
urania-dev
2024-08-07 20:11:29 +02:00
parent 24d43de758
commit 9d37d478b9
294 changed files with 36593 additions and 29001 deletions

View File

@@ -5,7 +5,7 @@ node_modules
.env .env
.env.example .env.example
.git .git
./prisma ./prisma/db.sqlite
.gitattributes .gitattributes
.eslintignore .eslintignore
.eslintrc.cjs .eslintrc.cjs

View File

@@ -1,20 +1,11 @@
AUTH_SECRET= ## openssl rand -base64 32 # Environment variables declared in this file are automatically made available to Prisma.
DB_HOST= # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
DB_PASS=
DB_PORT=6379 # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
DB_IDX=0 # See the documentation for all the connection string options: https://pris.ly/d/connection-strings
ENABLE_LIMITS=false
ENABLE_SIGNUP=false DATABASE_URL="file:./db.sqlite"
ENABLE_HOME=false TOKEN_SECRET=
DEFAULT_THEME=dark ORIGIN=https://labs.urania.dev
DEFAULT_LANG=en APPNAME="Snapp.li"
LOCALIZATION_FOLDER=/home/snapp/app/translations PUBLIC_SNAPP_VERSION=0.8-beta
MAX_SHORT_URL=10
MAX_USAGES=0
MAX_RPM=0
MAX_RPD=0
VIRUSTOTAL_API_KEY=
UMAMI_URL=
UMAMI_WEBSITE_ID=
PUBLIC_URL=http:/example.org
ORIGIN=http://example.org

13
.gitignore vendored
View File

@@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.vscode
.gitignore
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
./src/lib/server/geo-db/geolite-2-city.mmdb

View File

@@ -54,6 +54,13 @@
- 0.6.2 - 0.6.2
- minor clean up of changelog - minor clean up of changelog
- minor clean up of csv auth needed - minor clean up of csv auth needed
---
### first rebase
---
- 0.7.test - 0.7.test
- Import CSV from previous version, first of all - Import CSV from previous version, first of all
- Rebuilt on top of Redis (with RedisInsight UI for a major QOL upgrade) instead of SQLITE, now it doesn't relies on third part libraries and my cron abilities for expiring urls - Rebuilt on top of Redis (with RedisInsight UI for a major QOL upgrade) instead of SQLITE, now it doesn't relies on third part libraries and my cron abilities for expiring urls
@@ -76,4 +83,16 @@
- 0.7.3 - 0.7.3
- Added Spanish and Galician languages by [cabaseira](https://github.com/cabaseira) - Added Spanish and Galician languages by [cabaseira](https://github.com/cabaseira)
- Minor fix to unsecure HTTP Edit to fix [Issue #26](https://github.com/urania-dev/snapp/issues/26) - Minor fix to unsecure HTTP Edit to fix [Issue #26](https://github.com/urania-dev/snapp/issues/26)
- Removed some dev console.logs. - Removed some dev console.logs.
---
### second rebase
---
- 0.8-beta
- on the road to version 1: This is a new rebuilt on top of Svelte5, Tailwind, Prisma. It's a semplification in order to keep it more mantainable in the future.
- Rebase of the project, new code for the same features as before.
- Prisma comes back again instead of Redis, this should allow me to change the database behind the project and create different images for mysql pg and sqlite
- fixed long time running bugs as not resettable secrets of usages, api endpoint with form submission protections, and so on.

View File

@@ -1,46 +0,0 @@
# Use an official Node.js runtime as the base image
FROM node:18-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the container
COPY package*.json /app
RUN npm i
# Copy the source code to the container
COPY . /app
# Build the SvelteKit app
ENV SNAPP_VERSION=0.7.3
ENV AUTH_SECRET=lFNiU7T98/44Qlqb4hMUkVcLOpijEI7z722Kxhv4O2Y=
ENV ALLOW_UNSECURE_HTTP=false
ENV DB_HOST=100.64.0.21
ENV DB_PASS=
ENV DB_PORT=6379
ENV DB_IDX=0
ENV ENABLE_LIMITS=false
ENV ENABLE_SIGNUP=true
ENV ENABLE_HOME=true
ENV DEFAULT_THEME=dark
ENV DEFAULT_LANG=en
ENV LOCALIZATION_FOLDER=/app/translations
ENV MAX_SHORT_URL=10
ENV PUBLIC_UMAMI_WEBSITE_ID: ${P_UMAMI_WEB_ID} # this allow creator to enable metrics on public https://snapp.li homepage
ENV PUBLIC_UMAMI_URL: ${P_UMAMI_WEBSITE_URL} # this allow creator to enable metrics on public https://snapp.li homepage
ENV MAX_USAGES=0
ENV MAX_RPM=0
ENV MAX_RPD=0
ENV VIRUSTOTAL_API_KEY=
ENV PUBLIC_URL=http://localhost:3000
RUN npm run build
# Expose the port on which the app will run
EXPOSE 3000
# Set the command to run the app
CMD node -r dotenv/config build

162
README.md
View File

@@ -1,147 +1,45 @@
# Snapp # Snapp
Are you looking for a reliable solution for self-hosted URL shortening? Look no further! Snapp is the perfect tool for individuals and businesses seeking control over their URL management. If you're seeking a self-hosted URL shortening solution, Snapp might be what you need. It's designed for those who value control over their URL management and want to explore various technologies.
## Our Features ## A Brief Introduction
- **Intuitive User Interface:** Snapp provides an intuitive user interface for seamless link shortening. Learn how to get started! This project began as a personal endeavor to explore new technologies and make use of free time. With version 0.7, some development issues emerged, prompting a complete redesign and rebuild. By version 0.8, we've laid the groundwork for what will become the first version 1.
- **Secure Authentication:** Enjoy a secure experience with authentication sessions and hashed passwords. Your information is in safe hands.
- **Custom Short Codes:** Create personalized short codes for your links to make them memorable and easy to share. Currently, you can migrate URLs between versions using a CSV export tool. Note that these files are only valid for direct transitions from one version to the next; for example, exports from version 0.6 to 0.7 won't work for moving from 0.7 to 0.8. Weve reverted to using Prisma to ensure a more stable and maintainable platform going forward.
- **Expiration:** Control the lifespan of your links with expiration dates. Set expiry dates for added security or let them stay active indefinitely.
- **Secret Links:** Add an extra layer of protection with secret links. Choose to share links with a selected audience using unique secrets. This latest version supports multiple architectures, including ARM and ARM64 platforms, and offers integration with various databases, now accessible with just a ENV Variable.
- **Usage Analytics:** Empower yourself with detailed analytics for every link you create. Snapp gathers metrics anonymously, providing insights into link engagements.
- **Extend Metrics:** Integrate your Snapp Instance with your self-hosted or cloud Umami Analytics instance for advanced metrics of your Snapp. ## Features
- **Check URL Reputation:** Secure the links passing through your Snapp instance with a check on VirusTotal API reputation.
- **REST API:** Community requested features that enable REST API endpoints to create and manage your Snapps remotely. Read all Swagger Docs [here](https://snapp.li/dashboard/docs). - **Intuitive User Interface:** Snapp offers a user-friendly interface for easy link shortening.
- **Secure Authentication:** Enjoy secure sessions for your user. Their information is protected.
- **Custom Short Codes:** Personalize your short codes to make your links memorable and easy to share.
- **Expiration Dates:** Manage link lifespans with expiration dates. You can set expiry dates for added security or let links remain active indefinitely.
- **Secret Links:** Enhance security with secret links, allowing you to share with a select audience using unique secrets.
- **Usage Analytics:** Access detailed, anonymous analytics for your links. Snapp provides insights into link engagements.
- **Extended Metrics:** Integrate Snapp with your self-hosted or cloud-based Umami Analytics for advanced metrics.
- **URL Reputation Check:** Ensure the safety of links with VirusTotal API reputation checks.
- **REST API:** Community-requested REST API endpoints enable remote management of your Snapp instance. Find all Swagger Docs [here](https://snapp.li/dashboard/docs).
## Getting Started ## Getting Started
Snapp is a self-hostable open-source platform. Snapp is an open-source platform you can host yourself.
### Manual Installation ### Testing Version 0.8
To run Snapp, you need an environment with NodeJS installed and available. For the 0.8 version, youll need to migrate URLs using the CSV Exporter. Heres a sample configuration:
1. Clone the git repository:
```
git clone https://github.com/urania-dev/snapp.git
```
2. Install dependencies:
```
npm install
```
3. Copy and edit the `.env.example` file:
```
cp .env.example .env && nano .env
```
4. Develop and extend Snapp on your server (optional):
```
npm run dev
```
5. Build the application:
```
npm run build
```
6. Run and enjoy!
```
node -r dotenv/config build
```
### Using Docker Container
Simply type in your terminal:
```
docker run -p 3000:3000 uraniadev/snapp:latest
```
If you run into CORS errors, remember to set the `PUBLIC_URL` and `ORIGIN` environment variables:
```
docker run -p 3000:3000 \
-e ORIGIN=https://example.com \
-e PUBLIC_URL=https://example.com \
uraniadev/snapp:latest
```
### Testing 0.7.test version
At the moment the 0.7.test has major changes and need to migrate shortened url with CSV Exporter from old to this version:
Read more and have docker compose in [announcement discussion](https://github.com/urania-dev/snapp/discussions/16).
```yml ```yml
version: '3'
services: services:
redis:
image: redis/redis-stack:latest
# ports: # you can specify LOCAL_IP OR VPN_IP to make db or redis insight available privately
# - (LOCAL_IP or VPN_IP):6379:6379/tcp
# - (LOCAL_IP or VPN_IP):8001:8001
volumes:
- /home/snapp/redis/test:/data:rw
# this make sure to enable persistance through docker restarts
# and shutdowns or updates -- change it to a folder of your choise
- /etc/localtime:/etc/localtime:ro
networks:
- snapp-stack
environment:
REDIS_ARGS: '--save 60 1 --appendonly yes' # Optional: `--requirepass mypassword`
snapp: snapp:
image: uraniadev/snapp:0.7 image: uraniadev/snapp:0.8
ports: ports:
- 3000:3000 - 3000:3000
volumes: environment:
# provide origin json downloadable DATABASE_URL: "file:./db.sqlite"
# from github if you intend to use it DATABASE_PROVIDER: sqlite # mysql | sqlite | pg
# TOKEN_SECRET: # openssl rand -base64 32
# - /home/snapp/app/translations:/app/translations:ro ORIGIN: https://example.com
# - /home/snapp/redis/theme/theme.css:/app/static/custom-theme.css
# See (Discussion about theming)[https://github.com/urania-dev/snapp/discussions/18]
networks:
- snapp-stack
environment:
AUTH_SECRET: very-secure-and-long-pass-words # random string, generate it with bash: openssl rand -base64 32
DB_HOST: redis
# DB_PASS: # Optional: Requires `--requirepass mypassword` in REDIS_ARGS
# DB_PORT: 6379
# DB_IDX: 0
# ALLOW_UNSECURE_HTTP: false
# ENABLE_LIMITS: false
# ENABLE_SIGNUP: true
# ENABLE_HOME: false
# DEFAULT_THEME: dark
# DEFAULT_LANG: en
# LOCALIZATION_FOLDER: /app/translations
# MAX_SHORT_URL: 10
# MAX_USAGES: 0
# MAX_RPM: 0
# MAX_RPD: 0
# UMAMI_WEBSITE_ID:
# UMAMI_URL:
# VIRUSTOTAL_API_KEY:
# PUBLIC_URL: http://host:5173
# ORIGIN: http://host:5173
## all commented vars are optional.
## Note that omitting PUBLIC_URL and ORIGIN
## app expects to be used from http://localhost:3000
networks:
snapp-stack: # Snapp Network so Snapp and redis can communicate but redis is isolated from the wan
external: false
``` ```
## Migration
The latest versions of Snapp include CSV Export to facilitate migration. Simply log in and import your URLs from the dashboard, and continue from where you left.
## The Stack
The technology involved:
- Svelte Kit
- Redis
- Auth.js
- Skeleton
- MaxMind
- Lucide

33
eslint.config.js Normal file
View File

@@ -0,0 +1,33 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];

10010
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1,67 @@
{ {
"name": "app", "name": "snapp",
"version": "0.0.1", "version": "0.8",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@skeletonlabs/skeleton": "^2.7.1", "@sveltejs/kit": "^2.5.20",
"@skeletonlabs/tw-plugin": "0.3.1", "@sveltejs/vite-plugin-svelte": "^3.1.1",
"@sveltejs/adapter-auto": "^3.0.0", "@types/eslint": "^9.6.0",
"@sveltejs/kit": "^2.0.0", "@types/nodemailer": "^6.4.15",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/papaparse": "^5.3.14",
"@tailwindcss/forms": "0.5.7", "@types/swagger-ui": "^3.52.4",
"@tailwindcss/typography": "0.5.10", "autoprefixer": "^10.4.20",
"@types/bcrypt": "^5.0.2", "eslint": "^9.8.0",
"@types/eslint": "8.56.0", "eslint-config-prettier": "^9.1.0",
"@types/node": "20.11.4", "eslint-plugin-svelte": "^2.43.0",
"@types/nodemailer": "^6.4.14", "globals": "^15.9.0",
"@types/papaparse": "^5.3.14", "lucia": "^3.2.0",
"@types/pell": "^1.0.4", "postcss": "^8.4.41",
"@types/qrcode": "^1.5.5", "prettier": "^3.3.3",
"@types/swagger-ui": "^3.52.4", "prettier-plugin-svelte": "^3.2.6",
"@typescript-eslint/eslint-plugin": "^6.0.0", "prettier-plugin-tailwindcss": "^0.6.5",
"@typescript-eslint/parser": "^6.0.0", "prisma": "5.17.0",
"autoprefixer": "10.4.16", "shiki": "^1.12.1",
"eslint": "^8.56.0", "svelte": "5.0.0-next.210",
"eslint-config-prettier": "^9.1.0", "svelte-check": "^3.8.5",
"eslint-plugin-svelte": "^2.35.1", "sveltekit-search-params": "^2.1.2",
"lucide-svelte": "^0.311.0", "tailwindcss": "^3.4.7",
"postcss": "8.4.33", "typescript": "^5.5.4",
"prettier": "^3.1.1", "typescript-eslint": "^8.0.1",
"prettier-plugin-svelte": "^3.1.2", "vite": "^5.3.5"
"svelte": "^4.2.7", },
"svelte-check": "^3.6.0", "type": "module",
"swagger-ui": "^5.11.3", "dependencies": {
"tailwindcss": "3.4.1", "@amcharts/amcharts5": "^5.10.0",
"tslib": "^2.4.1", "@internationalized/date": "^3.5.5",
"typescript": "^5.0.0", "@lucia-auth/adapter-prisma": "^4.0.1",
"vite": "^5.0.3", "@node-rs/argon2": "^1.8.3",
"vite-plugin-tailwind-purgecss": "0.2.0" "@prisma/client": "5.17.0",
}, "@sveltejs/adapter-node": "^5.2.0",
"type": "module", "@types/jsonwebtoken": "^9.0.6",
"dependencies": { "@types/ua-parser-js": "^0.7.39",
"@auth/core": "^0.20.0", "chart.js": "^4.4.3",
"@auth/sveltekit": "^0.5.1", "clsx": "^2.1.1",
"@auth/unstorage-adapter": "^1.1.1", "country-to-iso": "^1.3.0",
"@floating-ui/dom": "1.5.4", "date-fns": "^3.6.0",
"@internationalized/date": "^3.5.1", "dotenv": "^16.4.5",
"@sveltejs/adapter-node": "^3.0.1", "jsonwebtoken": "^9.0.2",
"@types/ua-parser-js": "^0.7.39", "maxmind": "^4.3.21",
"apexcharts": "^3.45.2", "nodemailer": "^6.9.14",
"bcrypt": "^5.1.1", "oslo": "^1.2.1",
"bits-ui": "^0.15.1", "papaparse": "^5.4.1",
"chart.js": "^4.4.1", "svelte-i18n": "^4.0.0",
"chartjs-chart-geo": "^4.2.8", "svelte-sonner": "^0.3.27",
"clsx": "^2.1.0", "swagger-ui": "^5.17.14",
"date-fns": "^3.3.1", "tailwind-merge": "^2.4.0",
"dotenv": "^16.4.5", "ua-parser-js": "^1.0.38"
"highlight.js": "11.9.0", }
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.2",
"maxmind": "^4.3.18",
"nodemailer": "^6.9.8",
"papaparse": "^5.4.1",
"qrcode": "^1.5.3",
"redis": "^4.6.12",
"redis-om": "^0.4.3",
"sqlite3": "^5.1.7",
"svelte-sonner": "^0.3.7",
"tailwind-merge": "^2.2.0",
"tailwind-variants": "^0.1.20",
"ua-parser-js": "^1.0.37",
"unstorage": "^1.10.1"
}
} }

6951
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

134
prisma/schema.mysql.prisma Normal file
View File

@@ -0,0 +1,134 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id
sessions Session[]
username String @unique
password_hash String
email String @unique
settings Setting[]
notes String?
role String @default("user")
token Token[]
snapps Snapp[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usages Usages[]
@@map("users")
}
model Session {
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@map("sessions")
}
model Password_reset {
token_hash String @unique
userId String
expiresAt DateTime
@@index(userId)
@@map("password_reset")
}
model Setting {
id String @id @unique @default(cuid())
field String
value String
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@index([field])
@@index([field, userId])
@@map("settings")
}
model Token {
key String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@map("tokens")
}
model Vtapicache {
id String @id @default(cuid())
created DateTime @default(now())
domain String @unique
result String
}
model Watchlist {
id String @id @default(cuid())
created DateTime @default(now())
username String?
domain String?
allowed Boolean
@@map("watchlists")
}
model Snapp {
id String @id @default(cuid())
shortcode String @unique
original_url String
created DateTime @default(now())
secret String?
max_usages Int @default(-1)
hit Int @default(0)
used Int @default(0)
notes String?
expiration DateTime?
disabled Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
usages Usages[]
@@index(shortcode)
@@index([created])
@@index([shortcode, created])
@@map("snapps")
}
model Usages {
id String @id @default(cuid())
timestamp DateTime @default(now())
snappId String
snappUserId String
language String?
user_agent String?
referrer String?
device String?
country String?
region String?
city String?
os String?
browser String?
cpu String?
user User @relation(fields: [snappUserId], references: [id], onDelete: Cascade)
snapp Snapp @relation(fields: [snappId], references: [id], onDelete: Cascade)
@@map("usages")
}

134
prisma/schema.pg.prisma Normal file
View File

@@ -0,0 +1,134 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgres"
url = env("DATABASE_URL")
}
model User {
id String @id
sessions Session[]
username String @unique
password_hash String
email String @unique
settings Setting[]
notes String?
role String @default("user")
token Token[]
snapps Snapp[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usages Usages[]
@@map("users")
}
model Session {
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@map("sessions")
}
model Password_reset {
token_hash String @unique
userId String
expiresAt DateTime
@@index(userId)
@@map("password_reset")
}
model Setting {
id String @id @unique @default(cuid())
field String
value String
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@index([field])
@@index([field, userId])
@@map("settings")
}
model Token {
key String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@map("tokens")
}
model Vtapicache {
id String @id @default(cuid())
created DateTime @default(now())
domain String @unique
result String
}
model Watchlist {
id String @id @default(cuid())
created DateTime @default(now())
username String?
domain String?
allowed Boolean
@@map("watchlists")
}
model Snapp {
id String @id @default(cuid())
shortcode String @unique
original_url String
created DateTime @default(now())
secret String?
max_usages Int @default(-1)
hit Int @default(0)
used Int @default(0)
notes String?
expiration DateTime?
disabled Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
usages Usages[]
@@index(shortcode)
@@index([created])
@@index([shortcode, created])
@@map("snapps")
}
model Usages {
id String @id @default(cuid())
timestamp DateTime @default(now())
snappId String
snappUserId String
language String?
user_agent String?
referrer String?
device String?
country String?
region String?
city String?
os String?
browser String?
cpu String?
user User @relation(fields: [snappUserId], references: [id], onDelete: Cascade)
snapp Snapp @relation(fields: [snappId], references: [id], onDelete: Cascade)
@@map("usages")
}

134
prisma/schema.sqlite.prisma Normal file
View File

@@ -0,0 +1,134 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id
sessions Session[]
username String @unique
password_hash String
email String @unique
settings Setting[]
notes String?
role String @default("user")
token Token[]
snapps Snapp[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usages Usages[]
@@map("users")
}
model Session {
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@map("sessions")
}
model Password_reset {
token_hash String @unique
userId String
expiresAt DateTime
@@index(userId)
@@map("password_reset")
}
model Setting {
id String @id @unique @default(cuid())
field String
value String
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@index([field])
@@index([field, userId])
@@map("settings")
}
model Token {
key String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
created DateTime @default(now())
@@map("tokens")
}
model Vtapicache {
id String @id @default(cuid())
created DateTime @default(now())
domain String @unique
result String
}
model Watchlist {
id String @id @default(cuid())
created DateTime @default(now())
username String?
domain String?
allowed Boolean
@@map("watchlists")
}
model Snapp {
id String @id @default(cuid())
shortcode String @unique
original_url String
created DateTime @default(now())
secret String?
max_usages Int @default(-1)
hit Int @default(0)
used Int @default(0)
notes String?
expiration DateTime?
disabled Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
usages Usages[]
@@index(shortcode)
@@index([created])
@@index([shortcode, created])
@@map("snapps")
}
model Usages {
id String @id @default(cuid())
timestamp DateTime @default(now())
snappId String
snappUserId String
language String?
user_agent String?
referrer String?
device String?
country String?
region String?
city String?
os String?
browser String?
cpu String?
user User @relation(fields: [snappUserId], references: [id], onDelete: Cascade)
snapp Snapp @relation(fields: [snappId], references: [id], onDelete: Cascade)
@@map("usages")
}

42
src/app.css Normal file
View File

@@ -0,0 +1,42 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
* {
box-sizing: border-box;
color: inherit;
outline: transparent;
}
:where(a),
:where(a:focus) {
border: none;
outline: none;
}
.datepicker {
@apply text-white -hue-rotate-0 invert-0 dark:-hue-rotate-180 dark:invert;
}
.datepicker-cell {
@apply text-neutral-50 dark:text-neutral-950;
}
pre {
@apply whitespace-pre-wrap -hue-rotate-180 invert md:whitespace-pre dark:-hue-rotate-0 dark:invert-0;
}
html,
body {
@apply bg-neutral-50 text-neutral-950 transition-colors dark:bg-neutral-950 dark:text-neutral-50;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
justify-content: center;
align-items: center;
}
.link {
@apply underline decoration-2 underline-offset-2 transition-all hover:text-pink-500 focus:text-pink-500;
}

60
src/app.d.ts vendored
View File

@@ -1,47 +1,31 @@
import '@auth/sveltekit';
import type { _RedisClient } from '$lib/db';
declare module '@auth/sveltekit' {
interface User {
/** comment **/
id: string;
}
interface Session {
user: {
id: string;
};
}
}
declare module '@auth/core/types' {
interface User {
/** comment **/
id: string;
}
interface Session {
user: {
id: string;
};
}
}
declare global { declare global {
var _redis: _RedisClient; type Language = 'it' | 'en' | 'es' | 'ga';
type MenuItem = {
label: string;
url: string;
visible: boolean;
active: boolean;
icon: string;
css?: string;
};
type SvelteFetch = {
(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
(input: string | URL | globalThis.Request, init?: RequestInit): Promise<Response>;
};
namespace App { namespace App {
// interface Error {}
interface Locals { interface Locals {
theme: 'dark' | 'light' | string; user: import('lucia').User | null;
lang: string; session: import('lucia').Session | null;
lang: Language;
theme: string;
} }
// interface PageData {}
// interface PageState {}
// interface Platform {}
} }
namespace svelteHTML { var _db: import('$lib/server/db/database').Database;
interface HTMLAttributes<T> { var _prisma: import('@prisma/client').PrismaClient;
'on:long'?: (event: Event) => void var _rpd_limiter: import('$lib/server/ratelimiter').SlidingWindowCounter;
} var _rpm_limiter: import('$lib/server/ratelimiter').SlidingWindowCounter;
}
} }
export {}; export {};

View File

@@ -2,17 +2,19 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<meta <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
name="viewport" <link
content="width=device-width, user-scalable=no, href="https://fonts.googleapis.com/css2?family=Fira+Mono&family=Nunito:ital,wght@0,200..1000;1,200..1000&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0" rel="stylesheet"
/> />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<link rel="stylesheet" type="text/css" href="%sveltekit.assets%/custom-theme.css" />
</head> </head>
<body data-sveltekit-preload-data="hover" data-theme="snappTheme"> <body data-sveltekit-preload-data="hover">
<div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<script src="https://www.amcharts.com/lib/5/geodata/worldLow.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,56 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
.bg-gotham-dark {
/* @apply bg-[radial-gradient(ellipse_at_top_left,_var(--tw-gradient-stops))] from-surface-700 to-surface-800; */
@apply bg-surface-900;
}
.bg-gotham {
/* @apply bg-[radial-gradient(ellipse_at_top_left,_var(--tw-gradient-stops))] from-surface-300 to-surface-400; */
@apply bg-surface-300;
}
html,
body {
@apply h-full overflow-hidden;
@apply bg-gotham;
}
html.dark body {
@apply bg-gotham-dark;
}
select,
input,
button {
@apply outline-0 ring-0 border-none;
}
.link {
@apply underline underline-offset-4 text-primary-800-100-token;
}
:where(.page) {
@apply flex flex-col gap-4 p-2 px-4 transition-[padding] max-w-6xl container mx-auto;
}
.btn {
@apply h-8;
}
.table tbody td {
@apply py-0 align-middle h-12;
}
.table thead th {
@apply text-base variant-glass py-2 h-12 font-semibold tracking-wide;
}
.table-header {
@apply text-[0.625rem] uppercase font-bold text-surface-800-100-token;
}
svg:not(#map) {
pointer-events: none;
}

View File

@@ -1,137 +1,75 @@
// src/hooks.server.js import { building } from '$app/environment';
import { lucia } from '$lib/server/auth';
import { database } from '$lib/server/db/database';
import { RPD, RPM } from '$lib/server/ratelimiter';
import { ENABLE_LIMITS, MAX_REQUESTS_PER_DAY, MAX_REQUESTS_PER_MINUTE } from '$lib/utils/constants';
import type { Handle } from '@sveltejs/kit';
import { locale } from 'svelte-i18n';
import { SvelteKitAuth, type Session } from '@auth/sveltekit'; // INIT DB
import CredentialsProvider from '@auth/core/providers/credentials'; const run_init_functions = async () => {
import { sequence } from '@sveltejs/kit/hooks'; database;
import { type Handle } from '@sveltejs/kit';
import { db } from '$lib/db';
import { env } from '$env/dynamic/private';
import type { JWT } from '@auth/core/jwt';
import bcrypt from 'bcrypt';
const snappHandler = (async ({ event, resolve }) => { const is_api_limited = database.settings.parse(await database.settings.get(ENABLE_LIMITS), true);
const locals = event.locals; const rpd = is_api_limited
? parseInt((await database.settings.get(MAX_REQUESTS_PER_DAY))?.value || '0')
: 0;
const rpm = is_api_limited
? parseInt((await database.settings.get(MAX_REQUESTS_PER_MINUTE))?.value || '0')
: 0;
const session = await locals.getSession(); if (is_api_limited) {
let theme = event.cookies.get('snapp:theme')?.toString(); RPD.configure(24 * 60 * 60 * 1000, rpd);
let lang = event.cookies.get('snapp:lang')?.toString(); RPM.configure(60 * 1000, rpm);
if (!theme || !lang) { }
const user = session !== null };
? await db.users.fetch(session?.user.id).then((user) => user as DBUser)
: null;
theme = user?.settings?.theme ?? env.DEFAULT_THEME ?? 'dark'; if (!building) await run_init_functions();
lang = user?.settings?.lang ?? env.DEFAULT_LANG ?? 'en';
export const handle: Handle = async ({ event, resolve }) => {
const lang = event.cookies.get('snapp:lang')?.toString() || 'en';
const theme = event.cookies.get('snapp:theme')?.toString() || null;
if (theme) event.locals.theme = theme;
if (lang) {
locale.set(lang);
event.locals.lang = lang as Language;
} }
event.locals.theme = theme; const sessionId = event.cookies.get(lucia.sessionCookieName);
event.locals.lang = lang; if (!sessionId) {
event.locals.user = null;
event.locals.session = null;
return resolve(event, {
transformPageChunk({ html }) {
if (theme && theme !== 'dark') return html.replace('class="dark"', 'class=""');
return html;
}
});
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
event.cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
}
event.locals.user = user;
event.locals.session = session;
return await resolve(event, { return await resolve(event, {
transformPageChunk({ html }) { transformPageChunk({ html }) {
let newHTML = html; if (theme && theme !== 'dark') return html.replace('class="dark"', 'class=""');
if (typeof theme === 'string' && theme === 'light') return html;
newHTML = newHTML.replace('dark', 'light');
if (typeof lang === 'string' && lang !== 'en') newHTML = newHTML.replace('en', lang);
return newHTML;
} }
}); });
}) satisfies Handle; };
const authHandler = SvelteKitAuth({
providers: [
CredentialsProvider({
credentials: {
username: { label: 'Username', type: 'text', placeholder: 'jsmith' },
password: { label: 'Password', type: 'password' }
},
async authorize({ username, password }) {
try {
const user = await db.users
.search()
.where('username')
.equals(username as string)
.first();
if (!user) throw new Error('auth:user:not:found');
if (user && (await bcrypt.compare(password as string, user.hash as string)))
return {
id: user.id as string
};
else throw new Error('unmatching ssr login');
} catch (error) {
throw error;
}
}
})
],
session: { strategy: 'jwt' },
callbacks: {
jwt(params) {
const { token, user } = params;
if (user) return { ...token, id: user.id };
else return token;
},
session(params) {
let { token, session } = params as { session: Session; token: JWT };
if (token) session = { ...session, user: { ...session.user, id: token.id as string } };
return session;
}
},
pages: {
signIn: '/auth/sign-in'
},
trustHost: true
});
async function initializeDBIndexes() {
try {
await db.users.createIndex();
await db.apikeys.createIndex();
await db.snapps.createIndex();
await db.usages.createIndex();
await check_and_set(
'settings:app:limits:enabled',
env.ENABLE_LIMITS?.toString()?.toLowerCase()
);
await check_and_set(
'settings:app:limits:max:urls',
env.MAX_SHORT_URL?.toString()?.toLowerCase()
);
await check_and_set(
'settings:app:limits:max:usages',
env.MAX_USAGES?.toString()?.toLowerCase()
);
await check_and_set('settings:app:limits:max:rpm', env.MAX_RPM?.toString()?.toLowerCase());
await check_and_set('settings:app:limits:max:rpd', env.MAX_RPD?.toString()?.toLowerCase());
await check_and_set(
'settings:app:signup:enabled',
env.ENABLE_SIGNUP?.toString()?.toLowerCase()
);
await check_and_set('settings:app:home:enabled', env.ENABLE_HOME?.toString()?.toLowerCase());
await check_and_set('settings:app:smtp:host', env.SMTP_HOST?.toString()?.toLowerCase());
await check_and_set('settings:app:smtp:pass', env.SMTP_PASSWORD?.toString()?.toLowerCase());
await check_and_set('settings:app:smtp:port', env.SMTP_PORT?.toString()?.toLowerCase());
await check_and_set('settings:app:smtp:user', env.SMTP_USER?.toString()?.toLowerCase());
await check_and_set('settings:app:smtp:from', env.SMTP_FROM?.toString()?.toLowerCase());
await check_and_set('settings:app:allow:unsecure:http', env.ALLOW_UNSECURE_HTTP?.toString()?.toLowerCase());
await check_and_set('settings:api:key:vt', env.VIRUSTOTAL_API_KEY?.toString()?.toLowerCase());
await check_and_set(
'settings:api:key:umami:website:id',
env.UMAMI_WEBSITE_ID?.toString()?.toLowerCase()
);
await check_and_set('settings:api:key:umami:url', env.UMAMI_URL?.toString()?.toLowerCase());
} catch (err) {
console.log(err);
}
}
async function check_and_set(setting: string, value: string | null | undefined) {
const exists = await db.getSetting(setting);
if (!exists && value) await db.setSetting(setting, value);
}
initializeDBIndexes();
export const handle = sequence(authHandler, snappHandler);

View File

@@ -1,19 +0,0 @@
import { db } from '$lib/db';
import { error } from '@sveltejs/kit';
import getLanguage from './getLanguage';
export default async function authenticate(request: Request, locale?: Translation) {
const EN = locale ? locale : await getLanguage();
const token = request.headers.get('authorization')?.split('Bearer ')[1];
if (!token) throw error(401, { message: EN['auth:not:authorized'] });
const apiKey = (await db.apikeys.search().where('id').equals(token).first()) as DBAPIKey;
if (!apiKey) throw error(401, { message: EN['auth:not:authorized'] });
if (
!apiKey.roles ||
(!apiKey.roles.includes('admin') &&
!apiKey.roles.includes('user') &&
!apiKey.roles.includes('superadmin'))
)
throw error(401, { message: EN['auth:not:authorized'] });
return apiKey;
}

View File

@@ -1,16 +0,0 @@
import { env } from '$env/dynamic/private';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
export default async function getLanguage() {
try {
const exists = existsSync(`${env.LOCALIZATION_FOLDER}/${env.DEFAULT_LANG}.json`);
if (!exists) return [];
const jsonContent = JSON.parse(
await readFile(`${env.LOCALIZATION_FOLDER}/${env.DEFAULT_LANG}.json`, 'utf8')
);
return jsonContent;
} catch (error: any) {
console.error(`Error loading translation file: ${error.message}`);
}
}

View File

@@ -1,14 +0,0 @@
export default function getUrlParams(url: URL) {
let page: number = Number(url.searchParams.get('page')?.toString()?.trim());
let limit: number = Number(url.searchParams.get('limit')?.toString()?.trim());
let search = url.searchParams.get('q')?.toString()?.trim();
let sort = url.searchParams.get('sort')?.toString()?.trim();
let sortDir = url.searchParams.get('direction')?.toString()?.trim() ?? 'asc';
if (!page || isNaN(page)) page = 1;
if (!limit || isNaN(limit)) limit = 8;
let offset = +(+page - 1) * +limit;
return { page, limit, search, sort, sortDir, offset };
}

View File

@@ -1,15 +0,0 @@
import { Schema } from 'redis-om';
export default new Schema(
'apikey',
{
id: { type: 'string' },
user_id: { type: 'string' },
roles: { type: 'string[]' },
created: { type: 'date', sortable: true },
used: { type: 'date', sortable: true },
},
{
dataStructure: 'JSON'
}
);

View File

@@ -1,119 +0,0 @@
import { createClient } from 'redis';
import { Repository } from 'redis-om';
// schema
import userSchema from './users';
import apiKeysSchema from './apiKeys';
import snappsSchema from './snapps';
import usagesSchema from './usages';
// settings
import getSetting from './settings/get';
import setSetting from './settings/set';
// users
import signupUser from './users/signup';
import signinUser from './users/signin';
import updateUser from './users/update';
import deleteUser from './users/delete';
import admin from './users/admin';
// snapps
import shortenSnapp from './snapps/shorten';
import editSnapp from './snapps/edit';
import authorship from './snapps/authorship';
import checkRPMLimit from './rpm';
import checkRPDLimit from './rpd';
import isWhiteListed from './users/isWhiteListed';
import isBlackListed from './users/isBlackListed';
import isBlackListedEmail from './users/isEmailBlackListed';
import trackMaxURLs from './settings/trackMaxURLs';
import trackRPDandRPM from './settings/trackRPDandRPM';
import hasWhiteList from './snapps/has_whitelist';
import checkRepoInfo from './repoInfo';
import { env } from '$env/dynamic/private';
export const domainZList = 'settings:app:banlists:website' as const;
export const usernameZList = 'settings:app:banlists:username' as const;
export const emailZList = 'settings:app:banlists:email' as const;
export const providerZList = 'settings:app:banlists:provider' as const;
export const whiteEmailZList = 'settings:app:whitelists:email' as const;
export const whiteProviderZList = 'settings:app:whitelists:provider' as const;
export class Database {
redis: typeof client;
users: Repository;
apikeys: Repository;
usages: Repository;
snapps: Repository;
constructor(redis: typeof client) {
this.redis = redis;
this.users = new Repository(userSchema, redis);
this.snapps = new Repository(snappsSchema, redis);
this.apikeys = new Repository(apiKeysSchema, redis);
this.usages = new Repository(usagesSchema, redis);
signinUser.bind(this);
signupUser.bind(this);
updateUser.bind(this);
deleteUser.bind(this);
admin.bind(this);
shortenSnapp.bind(this);
editSnapp.bind(this);
authorship.bind(this);
getSetting.bind(this);
setSetting.bind(this);
checkRPMLimit.bind(this);
checkRPDLimit.bind(this);
trackMaxURLs.bind(this);
trackRPDandRPM.bind(this);
isWhiteListed.bind(this);
isBlackListed.bind(this);
isBlackListedEmail.bind(this);
hasWhiteList.bind(this);
checkRepoInfo.bind(this);
}
signinUser = signinUser;
signupUser = signupUser;
updateUser = updateUser;
deleteUser = deleteUser;
admin = admin;
shorten = shortenSnapp;
edit = editSnapp;
authorship = authorship;
getSetting = getSetting;
setSetting = setSetting;
rpm = checkRPMLimit;
rpd = checkRPDLimit;
trackMaxURLs = trackMaxURLs;
trackRPDandRPM = trackRPDandRPM;
whitelisted = isWhiteListed;
blacklisted = isBlackListed;
blacklistedEmail = isBlackListedEmail;
hasWhiteList = hasWhiteList;
repoInfo = checkRepoInfo;
}
let password = env.DB_PASS ?? '';
let host = env.DB_HOST ?? '';
let port = env.DB_PORT ?? '6379';
let dbIndex = env.DB_IDX ?? '0';
async function getClient() {
if (env.DB_HOST)
return await createClient({
url: `redis://default:${password}@${host}:${port}/${dbIndex}`
})
.on('error', (err) => {
console.log(err);
})
.connect();
else throw new Error('This version of Snapps requires REDIS STACK, please read documentation');
}
const client = await getClient();
const db = new Database(client);
export { db };

View File

@@ -1,20 +0,0 @@
import type { Database } from '..';
export default async function checkRepoInfo(
this: Database,
fetch: (input: string | URL | Request, init?: RequestInit | undefined) => Promise<Response>
) {
const key = `settings:repo:info`;
const stored = await this.redis.get(key);
if (stored) {
return JSON.parse(stored);
} else {
const repoInfo = await (await fetch('https://api.github.com/repos/urania-dev/snapp')).json();
await this.redis.set(key, JSON.stringify(repoInfo));
await this.redis.expire(key, 60 * 60 * 24);
return repoInfo;
}
}

View File

@@ -1,26 +0,0 @@
import type { Database } from '..';
export default async function checkRPDLimit(this: Database, userId: string, rpdLimit: number = 0) {
// Key structure: rpd:{userId}:{date}
const key = `limits:rpd:${userId}`;
if (!rpdLimit) {
return true;
}
// Check if the user has already made a request on the current date
const count = await this.redis.get(key);
if (count && parseInt(count) >= rpdLimit) {
// User has exceeded the RPD limit
return false;
} else {
// Increment the counter or set it to 1 if it doesn't exist
await this.redis.incr(key);
// Expire the counter at the end of the current day
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
const secondsUntilMidnight = Math.ceil((midnight.getTime() - Date.now()) / 1000);
await this.redis.expire(key, secondsUntilMidnight);
return true;
}
}

View File

@@ -1,25 +0,0 @@
import type { Database } from '..';
export default async function checkRPMLimit(this: Database, userId: string, rpmLimit: number = 0) {
const key = `limits:rpm:${userId}`;
if (!rpmLimit) {
return true;
}
// Check if the user has already made a request in the current minute
const count = await this.redis.get(key);
if (count && parseInt(count) >= rpmLimit) {
// User has exceeded the RPM limit
return false;
} else {
// Increment the counter or set it to 1 if it doesn't exist
await this.redis.incr(key);
// Expire the counter at the end of the current minute
await this.redis.expire(key, 60);
return true;
}
}

View File

@@ -1,10 +0,0 @@
import type { Database } from '..';
const settingsHash = 'settings:global';
export default async function getSettings(this: Database, id: string, hash = settingsHash) {
try {
return await this.redis.hGet(hash, id);
} catch (error) {
console.log(error);
}
}

View File

@@ -1,16 +0,0 @@
import { Database } from '..';
const settingsHash = 'settings:global';
export default async function setSetting(
this: Database,
id: string,
value: string,
hash: string = settingsHash
) {
try {
await this.redis.hSet(hash, id, value);
return value;
} catch (error) {
console.log(error);
}
}

View File

@@ -1,29 +0,0 @@
import parseNumber from '$lib/utils/parseNumber';
import type { Database } from '..';
export default async function trackMaxURLs(this: Database, apiKey: DBAPIKey, _EN?: Translation) {
if (apiKey.roles.includes('admin') || apiKey.roles.includes('superadmin')) return false;
const is_limited = await this.getSetting('settings:app:limits:enabled').then(
(res) => res === 'true' || false
);
if (!is_limited) return false;
const global_limit_urls = await parseNumber(this.getSetting('settings:app:limits:max:urls'));
const user_limit = (
(await this.users.search().where('id').equals(apiKey.user_id).first()) as DBUser
)?.settings?.max;
const urls_by_this_user = await this.snapps
.search()
.where('user_id')
.equal(apiKey.user_id)
.returnCount();
const limit = user_limit?.urls ?? global_limit_urls;
if (!limit || urls_by_this_user <= limit) {
return false;
} else true;
}

View File

@@ -1,25 +0,0 @@
import parseNumber from '$lib/utils/parseNumber';
import type { Database } from '..';
export default async function trackRPDandRPM(this: Database, apiKey: DBAPIKey, _EN?: Translation) {
if (apiKey.roles.includes('admin') || apiKey.roles.includes('superadmin')) return false;
const is_limited = await this.getSetting('settings:app:limits:enabled').then(
(res) => res === 'true' || false
);
if (!is_limited) return false;
const global_limit_rpm = await parseNumber(this.getSetting('settings:app:limits:max:rpm'));
const global_limit_rpd = await parseNumber(this.getSetting('settings:app:limits:max:rpd'));
const user_limit = (
(await this.users.search().where('id').equals(apiKey.user_id).first()) as DBUser
)?.settings?.max;
const rpd = user_limit?.rpd ?? global_limit_rpd;
const rpm = user_limit?.rpm ?? global_limit_rpm;
const under_the_max_request_limit =
(await this.rpm(apiKey.user_id, rpm)) && (await this.rpd(apiKey.user_id, rpd));
if (!under_the_max_request_limit) return true;
return false
}

View File

@@ -1,23 +0,0 @@
import { db, type Database } from '..';
import SnappError from '../utils/snappError';
export default async function authorship(this: Database, { id, user_id }: Partial<DBSnapp>) {
if (!user_id) return new SnappError(400, { message: 'api:user:id:unset' });
if (!id) return new SnappError(400, { message: 'api:snapp:id:unset' });
const roles = ((await db.users.search().where('id').equal(user_id).first()) as DBUser)?.roles;
const is_admin = roles.includes('admin') || roles.includes('superadmin');
const query = this.snapps.search().where('id').equals(id);
if (!is_admin) query.and('user_id').equals(user_id);
const is_author = (await query.first()) as DBSnapp | null;
return {
status: 200,
is_author: is_author !== null,
can_edit: is_author !== null || is_admin === true || false
};
}

View File

@@ -1,115 +0,0 @@
import bcrypt from 'bcrypt';
import { type Database } from '..';
import SnappError from '../utils/snappError';
import { env } from '$env/dynamic/private';
import { extractDomain } from './shorten';
import { generateRandomString } from '$lib/utils/randomString';
export default async function edit(
this: Database,
{ id, original_url, shortcode, secret, user_id, max_usages, notes }: Partial<DBSnapp>,
fetch: (input: string | URL | Request, init?: RequestInit | undefined) => Promise<Response>,
expiration?: number
) {
if (!id) return new SnappError(404, { message: 'snapps:not:found' });
let snapp = await this.snapps.search().where('id').equals(id).first();
if (!user_id) return new SnappError(404, { message: 'auth:user:not:found' });
if (!snapp) return new SnappError(404, { message: 'snapps:not:found' });
const user = (await this.users.search().where('id').equal(user_id).first()) as DBUser;
if (!user) return new SnappError(404, { message: 'auth:not:authorized' });
const regex = new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/);
const allow_unsecure_http =
(await this.getSetting('settings:app:allow:unsecure:http'))?.toString().toLocaleLowerCase() ===
'true';
if (!original_url || typeof original_url !== 'string' || original_url.trim() === '')
return new SnappError(400, { message: 'snapps:original:url:unset' });
if (original_url) {
if (allow_unsecure_http === false && regex.test(original_url) === false)
return new SnappError(400, { message: 'snapps:original:url:invalid' });
snapp.original_url = original_url;
}
const domain = extractDomain(original_url);
if (!domain) return new SnappError(400, { message: 'snapps:original:url:invalid' });
const is_blacklisted = await this.blacklisted({ domain });
if (is_blacklisted) return new SnappError(400, { message: 'snapps:domain:blacklisted' });
const has_vt_key = await this.getSetting('settings:api:key:vt');
const vtFormData = new FormData();
vtFormData.set('url', original_url);
if (has_vt_key !== undefined) {
const res = await (
await fetch('/api/scan', {
method: 'post',
body: vtFormData
})
).json();
if (res.status !== 200) return new SnappError(500, { ...(res as object) });
const is_malicious = res.is_clean === false || false;
if (is_malicious)
return new SnappError(401, {
original_url: true,
message: 'snapps:vt:api:key:malicious'
});
}
if (notes) snapp.notes = notes;
if (max_usages) snapp.max_usages = max_usages;
try {
if (secret) {
snapp.has_secret = true;
snapp.secret = secret;
} else snapp.has_secret = false;
} catch (error) {
return new SnappError(400, { ...(error as object) });
}
try {
const banned_shortcodes = ['api', 'dashboard', 'auth'];
if (!shortcode || banned_shortcodes.includes(shortcode))
snapp.shortcode = generateRandomString(5);
else {
const exists = await this.snapps
.search()
.where('shortcode')
.equals(`${shortcode}*`)
.and('id')
.not.equal(snapp.id as string)
.count();
if (exists) snapp.shortcode = `${shortcode}-${exists}`;
else snapp.shortcode = shortcode;
}
} catch (error) {
return new SnappError(500, { ...(error as object) });
}
if (expiration) snapp.expiration = new Date(new Date().getTime() + expiration);
const newSnapp = (await this.snapps.save(snapp.id as string, snapp).then((snapp) => {
delete snapp.secret;
return snapp;
})) as DBSnapp;
if (expiration !== undefined) {
const validDate = isNaN(new Date(expiration).getTime());
if (validDate) return new SnappError(400, { message: 'snapps:date:invalid' });
await this.snapps.expire(newSnapp.id, expiration);
} else this.redis.persist('snapps:' + newSnapp.id);
return {
success: true,
status: 200,
snapp: { ...newSnapp },
expires: expiration
? new Date(new Date().getTime() + expiration * 1000).toLocaleDateString(env.DEFAULT_LANG)
: undefined
};
}

View File

@@ -1,12 +0,0 @@
import { whiteEmailZList, whiteProviderZList, type Database } from "..";
export default async function hasWhiteList(this: Database, ) {
const has_whitelists = await this.redis.zCard(whiteEmailZList);
const has_whitelisted_providers = await this.redis.zCard(whiteProviderZList);
if (
(!has_whitelists || has_whitelists === 0) &&
(!has_whitelisted_providers || has_whitelisted_providers === 0)
)
return false;
else return true;
}

View File

@@ -1,23 +0,0 @@
import { Schema } from 'redis-om';
export default new Schema(
'snapps',
{
id: { type: 'string' },
shortcode: { type: 'string' },
original_url: { type: 'string' },
created: { type: 'date', sortable: true },
user_id: { type: 'string' },
secret: { type: 'string' },
has_secret: { type: 'boolean' },
max_usages: { type: 'number', sortable: true },
used: { type: 'number', sortable: true },
notes: { type: 'text', sortable: true },
hit: { type: 'number', sortable: true },
expiration: { type: 'date', sortable: true },
disabled: { type: 'boolean' }
},
{
dataStructure: 'JSON'
}
);

View File

@@ -1,128 +0,0 @@
import bcrypt from 'bcrypt';
import { db, type Database } from '..';
import SnappError from '../utils/snappError';
import { randomUUID } from 'node:crypto';
import { generateRandomString } from '$lib/utils/randomString';
import { env } from '$env/dynamic/private';
export function extractDomain(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?]+)/);
return match ? match[1] : null;
}
export default async function shorten(
this: Database,
{ original_url, shortcode, secret, user_id, max_usages, notes }: Partial<DBSnapp>,
fetch: (input: string | URL | Request, init?: RequestInit | undefined) => Promise<Response>,
expiration?: number
) {
let snapp: Partial<DBSnapp> = {
id: randomUUID(),
user_id,
created: new Date(),
expiration: expiration ? new Date(new Date().getTime() + expiration) : undefined
};
if (!user_id) return new SnappError(404, { message: 'auth:user:not:found' });
const user = (await this.users.search().where('id').equal(user_id).first()) as DBUser;
if (!user) return new SnappError(404, { message: 'auth:not:authorized' });
const roles = user.roles;
const allow_unsecure_http =
(await this.getSetting('settings:app:allow:unsecure:http'))?.toString().toLocaleLowerCase() ===
'true';
const global_max_urls = await this.getSetting('settings:app:limits:max:urls');
const max_urls = user?.settings?.max?.urls ?? global_max_urls;
const urls = await this.snapps.search().where('user_id').equals(user_id).returnCount();
if (urls > max_urls && (!roles.includes('admin') || !roles.includes('superadmin')))
return new SnappError(400, { message: 'snapps:max:urls:reached' });
const httpRegexp = new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/);
if (!original_url || typeof original_url !== 'string' || original_url.trim() === '')
return new SnappError(400, { message: 'snapps:original:url:unset' });
if (original_url) {
if (allow_unsecure_http === false && httpRegexp.test(original_url) === false)
return new SnappError(400, { message: 'snapps:original:url:invalid' });
snapp.original_url = original_url;
}
const domain = extractDomain(original_url);
if (!domain) return new SnappError(400, { message: 'snapps:original:url:invalid' });
const is_blacklisted = await this.blacklisted({ domain });
if (is_blacklisted) return new SnappError(400, { message: 'snapps:domain:blacklisted' });
const has_vt_key = await this.getSetting('settings:api:key:vt');
const vtFormData = new FormData();
vtFormData.set('url', original_url);
if (has_vt_key !== undefined) {
const res = await (
await fetch('/api/scan', {
method: 'post',
body: vtFormData
})
).json();
if (res.status !== 200) return new SnappError(500, { ...(res as object) });
const is_malicious = res.is_clean === false || false;
if (is_malicious)
return new SnappError(401, {
original_url: true,
message: 'snapps:vt:api:key:malicious'
});
}
if (notes) snapp.notes = notes;
if (max_usages) snapp.max_usages = max_usages;
try {
if (secret) {
snapp.has_secret = true;
snapp.secret = await bcrypt
.genSalt(10)
.then((salt) => bcrypt.hash(secret as string, salt))
.then((hash) => hash);
} else snapp.has_secret = false;
} catch (error) {
return new SnappError(400, { ...(error as object) });
}
try {
const banned_shortcodes = ['api', 'dashboard', 'auth'];
if (!shortcode || banned_shortcodes.includes(shortcode))
snapp.shortcode = generateRandomString(5);
else {
const exists = await this.snapps.search().where('shortcode').equals(`${shortcode}*`).count();
if (exists) snapp.shortcode = `${shortcode}-${exists}`;
else snapp.shortcode = shortcode;
}
} catch (error) {
return new SnappError(500, { ...(error as object) });
}
const newSnapp = (await this.snapps.save(snapp.id!, snapp).then((snapp) => {
delete snapp.secret;
return snapp;
})) as DBSnapp;
if (expiration !== undefined) {
const validDate = isNaN(new Date(expiration).getTime());
if (validDate) return new SnappError(400, { message: 'snapps:date:invalid' });
await this.snapps.expire(newSnapp.id, expiration);
}
return {
success: true,
status: 200,
snapp: { ...newSnapp },
expires: expiration
? new Date(new Date().getTime() + expiration * 1000).toLocaleDateString(env.DEFAULT_LANG)
: undefined
};
}

View File

@@ -1,64 +0,0 @@
type DBSnapp = {
id: string;
original_url: string;
shortcode: string;
created: Date;
secret?: string;
has_secret: boolean;
user_id: string;
used: number;
max_usages: number;
expiration: Date;
disabled: boolean;
hit: number;
notes?: string;
};
interface DBSnappEnriched extends DBSnapp {
status: 'active' | 'disabled' | 'expired' | 'blacklisted';
ttl: number;
used: number;
}
type DBUser = {
id: string;
username: string;
password: string;
roles: string[];
updated: Date;
created: Date;
settings: {
theme: string;
lang: string;
max: {
rpd: number;
rpm: number;
urls: number;
};
};
hash?: string;
email?: string;
};
type DBAPIKey = {
id: string;
user_id: string;
roles: string[];
created: Date;
used?: Date;
};
type DBUsages = {
id: string;
timestamp: string;
snapp_id: string;
language: string;
user_agent: string;
device: string;
country: string;
city: string;
region: string;
cpu: string;
os: string;
browser: string;
};

View File

@@ -1,23 +0,0 @@
import { Schema } from 'redis-om';
export default new Schema(
'usages',
{
id: { type: 'string', sortable: true },
timestamp: { type: 'date', sortable: true },
snapp_id: { type: 'string' },
snapp_user_id: { type: 'string' },
language: { type: 'string' },
user_agent: { type: 'string' },
device: { type: 'string' },
country: { type: 'string' },
city: { type: 'string' },
region: { type: 'string' },
cpu: { type: 'string' },
os: { type: 'string' },
browser: { type: 'string' }
},
{
dataStructure: 'JSON'
}
);

View File

@@ -1,10 +0,0 @@
import { db, type Database } from '..';
import SnappError from '../utils/snappError';
export default async function admin(this: Database, id: string) {
const user = (await db.users.search().where('id').equal(id).first()) as DBUser;
if (!user) return new SnappError(404, { message: 'auth:user:not:found' });
const is_admin = user.roles.includes('admin') || user.roles.includes('superadmin');
return is_admin;
}

View File

@@ -1,38 +0,0 @@
import type { Database } from '..';
import SnappError from '../utils/snappError';
export default async function deleteProfile(this: Database, ...ids: string[]) {
if (!ids) return new SnappError(400, { message: 'api:user:id:unset' });
const errors = await Promise.all(
ids.map(async (id) => {
let user_to_delete = (await this.users.search().where('id').equal(id).first()) as DBUser;
if (!user_to_delete) return new SnappError(404, { message: 'auth:user:not:found' });
if (user_to_delete.roles.includes('superadmin'))
return new SnappError(401, { message: 'super:admin:protects' });
else return false;
})
);
let error_IDX: number | undefined = undefined;
const someError = errors.some((err, idx) => {
if (err !== false) {
error_IDX = idx;
return true;
}
return false;
});
if (someError) return errors[error_IDX!] as SnappError;
try {
return {
status: 200,
success: true,
message: 'auth:profile:deleted'
};
} catch (error) {
return new SnappError(500, { error, message: 'global:system:error' });
}
}

View File

@@ -1,22 +0,0 @@
import { Schema } from 'redis-om';
export default new Schema(
'users',
{
id: { type: 'string' },
username: { type: 'string' },
email: { type: 'string' },
password: { type: 'string' },
roles: { type: 'string[]' },
updated: { type: 'date', sortable: true },
created: { type: 'date', sortable: true },
theme: { type: 'string', path: '$.settings.theme' },
lang: { type: 'string', path: '$.settings.lang' },
urls: { type: 'number', path: '$.settings.max.urls' },
rpm: { type: 'number', path: '$.settings.max.rpm' },
rpd: { type: 'number', path: '$.settings.max.rpd' }
},
{
dataStructure: 'JSON'
}
);

View File

@@ -1,25 +0,0 @@
import { type Database, usernameZList, domainZList } from '..';
export default async function isBanned(
this: Database,
payload: { username?: string; domain?: string }
) {
let value: string;
let key: string;
if (payload.domain) {
key = domainZList;
value = payload.domain.trim();
const has_subdomain = value.split('.').length > 1;
if (has_subdomain) {
let main = await this.redis.zScore(key, value.split('.').slice(1).join('.'));
if (main) return true;
}
} else if (payload.username) {
key = usernameZList;
value = payload.username.trim();
}
const isBanned = await this.redis.zScore(key!, value!);
if (isBanned) return true;
else return false;
}

View File

@@ -1,11 +0,0 @@
import { type Database, emailZList, providerZList } from '..';
export default async function isBanned(this: Database, email: string) {
const isBannedProvider = await this.redis.zScore(providerZList, `@${email.split('@')[1]}`);
const isBanned = await this.redis.zScore(emailZList, email);
return {
email: isBanned !== null,
provider: isBannedProvider !== null
};
}

View File

@@ -1,13 +0,0 @@
import { whiteEmailZList, type Database, whiteProviderZList } from '..';
export default async function isWhiteListed(this: Database, email: string) {
const isWhiteListed = await this.redis.zScore(whiteEmailZList, `${email}`);
const isWhiteListedProvider = await this.redis.zScore(
whiteProviderZList,
`@${email.split('@')[1]}`
);
return {
email: isWhiteListed !== null,
provider: isWhiteListedProvider !== null
};
}

View File

@@ -1,45 +0,0 @@
import { db, type Database } from '..';
import bcrypt from 'bcrypt';
import SnappError from '../utils/snappError';
import { EntityId } from 'redis-om';
export default async function signin(
this: Database,
username: string | undefined,
password: string | undefined
) {
if (typeof username !== 'string' || username.trim() === '' || username.length < 3)
return new SnappError(400, { username: true, message: 'auth:username:unset' });
if (typeof password !== 'string' || password.trim() === '')
return new SnappError(400, {
password: true,
message: 'auth:password:unset'
});
let query = this.users.search().where('username').equals(username);
if (username?.includes('@')) query.or('email').equals(username);
const exists = await query.first();
if (!exists) return new SnappError(404, { message: 'auth:user:not:found', username: true });
const check_pwd = await bcrypt.compare(password, exists.hash as string);
const user_id = exists[EntityId];
if (user_id) await db.users.save(user_id, { ...exists, updated: new Date() });
if (!check_pwd)
return new SnappError(400, {
username: true,
password: true,
message: 'auth:wrong:credentials'
});
return {
succes: true,
status: 200,
username: exists.username,
password: false
};
}

View File

@@ -1,131 +0,0 @@
import { db, type Database } from '..';
import { randomUUID } from 'node:crypto';
import bcrypt from 'bcrypt';
import SnappError from '../utils/snappError';
export default async function (
this: Database,
{
username,
password,
email,
confirmPassword
}: {
username: string | undefined;
email: string | undefined;
password: string | undefined;
confirmPassword: string | undefined;
}
) {
const enabled_signup = await this.getSetting('settings:app:signup:enabled').then(
(res) => (res && res === 'true') || false
);
if (enabled_signup === false)
return new SnappError(500, {
message: 'auth:sign:up:disabled',
username: false,
password: false
});
if (typeof username !== 'string' || username.trim() === '' || username.length < 3)
return new SnappError(400, { message: 'auth:username:unset', username: true });
if (typeof email !== 'string' || email.trim() === '' || email.length < 3)
return new SnappError(400, { message: 'auth:email:unset', email: true });
if (typeof password !== 'string' || password.trim() === '')
return new SnappError(400, { message: 'auth:password:unset', password: true });
if (/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&\_\,*-]).{8,}$/.test(password) === false)
return new SnappError(400, {
password: true,
message: 'auth:password:guidelines'
});
const has_blacklisted_username = await db.blacklisted({ username });
if (has_blacklisted_username) return new SnappError(401, { message: 'auth:in:blacklist' });
const has_whitelists = await db.hasWhiteList();
if (email && /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email) === false) {
return new SnappError(400, {
email: true,
message: 'auth:email:invalid'
});
}
const exists_email = await db.users.search().where('email').equals(email).first();
if (exists_email) return new SnappError(400, { message: 'auth:email:exists' });
if (has_whitelists === true) {
const blacklist = await db.blacklistedEmail(email);
const whitelist = await db.whitelisted(email);
if (!whitelist.provider && !whitelist.email)
return new SnappError(401, { message: 'auth:not:in:whitelist' });
else if (blacklist.provider === true && whitelist.email === false)
return new SnappError(401, { message: 'auth:in:blacklist' });
else if (blacklist.email === true) return new SnappError(401, { message: 'auth:in:blacklist' });
} else {
const blacklist = await db.blacklistedEmail(email);
if (blacklist.provider || blacklist.email)
return new SnappError(401, { message: 'auth:in:blacklist' });
}
let user = await this.users
.search()
.where('username')
.equal(username as string)
.first();
if (user) return new SnappError(401, { message: 'auth:username:taken', user: true });
if (!confirmPassword)
return new SnappError(401, { message: 'auth:password:unmatch', confirmPassword: true });
if (password !== confirmPassword)
return new SnappError(400, {
message: 'auth:password:unmatch',
password: true,
confirmPassword: true
});
try {
const hash = await bcrypt
.genSalt(10)
.then((salt) => bcrypt.hash(password as string, salt))
.then((hash) => hash);
const newUser = {
id: randomUUID(),
username: username as string,
hash: hash,
email: email,
roles: ['user'] as string[],
created: new Date(),
updated: new Date()
};
const admins = await this.users
.search()
.where('roles')
.containsOneOf('admin', 'superadmin')
.first();
if (!admins) newUser.roles = ['admin', 'superadmin', ...newUser.roles];
user = await this.users.save(newUser.id, newUser);
return {
success: true,
status: 200,
user: {
id: user.id,
email: user.email,
username: user.username
} as DBUser
};
} catch (error) {
return new SnappError(500, { ...(error as object) });
}
}

View File

@@ -1,91 +0,0 @@
import { db, type Database } from '..';
import bcrypt from 'bcrypt';
import SnappError from '../utils/snappError';
export default async function updateProfile(
this: Database,
{
id,
username,
email,
password,
settings,
roles
}: {
id?: string;
email?: string | undefined;
username?: string | undefined;
password?: string | undefined;
settings?: DBUser['settings'];
roles?: string[];
}
) {
if (!id) return new SnappError(400, { message: 'api:user:id:unset' });
let updatedProfile = (await this.users.search().where('id').equal(id).first()) as DBUser;
if (!updatedProfile) return new SnappError(404, { message: 'auth:user:not:found' });
if (email && email.trim() !== '' && /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/.test(email) === true)
updatedProfile.email = email;
if (password) {
if (
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&\_\,*-]).{8,}$/.test(password) === false
)
return new SnappError(400, {
password: true,
message: 'auth:password:guidelines'
});
const hash = await bcrypt
.genSalt(10)
.then((salt) => bcrypt.hash(password as string, salt))
.then((hash) => hash);
updatedProfile.hash = hash;
}
if (username) {
const exists = await db.users
.search()
.where('username')
.equals(username)
.and('id')
.does.not.equals(id)
.first();
if (exists) return new SnappError(400, { message: 'auth:username:taken' });
updatedProfile.username = username;
}
if (settings)
updatedProfile.settings = {
...updatedProfile.settings,
theme: settings?.theme ?? updatedProfile.settings?.theme,
lang: settings?.lang ?? updatedProfile.settings?.lang ?? 'en'
};
if (roles && roles.length > 0) {
const newRoles = new Set([...roles]);
if (
Array.from(newRoles) !== updatedProfile.roles &&
updatedProfile.roles.includes('superadmin') &&
!Array.from(newRoles).includes('superadmin')
)
return new SnappError(401, { message: 'global:super:admin:protects' });
updatedProfile.roles = Array.from(newRoles);
}
try {
updatedProfile.updated = new Date();
await this.users.save(id, { ...updatedProfile, updated: new Date() });
return {
status: 200,
success: true,
message: 'auth:profile:saved',
user: JSON.parse(JSON.stringify(updatedProfile))
};
} catch (error) {
return new SnappError(500, { ...(error as object) });
}
}

View File

@@ -1,15 +0,0 @@
import type { NumericRange } from '@sveltejs/kit';
export default class SnappError {
status: NumericRange<400, 599>;
data: { [key: string]: any };
constructor(status: NumericRange<400, 599>, data: { [key: string]: any }) {
this.status = status;
this.data = { ...data };
}
valueOf(): [number, any] {
return [this.status as NumericRange<400, 599>, { ...(this.data || {}) }];
}
}

View File

@@ -1,107 +0,0 @@
<!doctype html>
<html
lang="en"
dir="ltr"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes" />
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>{{EMAIL_TITLE}}</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
</head>
<body class="body m-0 p-0" xml:lang="en" style="margin: 0; padding: 0">
<div
role="article"
aria-roledescription="email"
aria-label="email name"
lang="en"
dir="ltr"
style="font-size: medium; font-size: max(16px, 1rem); overflow: hidden; height: 100dvh"
>
<div
class="w-full h-full bg-slate-900 p-10"
style="width: 100%; background-color: #0f172a; padding: 40px"
>
<div
class="mx-auto w-full max-w-2xl bg-slate-700 p-4"
style="
margin-left: auto;
margin-right: auto;
width: 100%;
max-width: 42rem;
background-color: #334155;
padding: 16px;
"
>
<h3
class="font-sans mb-6 font-bold text-2xl"
style="
color: white;
margin-bottom: 24px;
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 24px;
font-weight: 700;
"
>
{APP_NAME}
</h3>
<h2
class="font-sans mb-4font-bold text-base"
style="
color: white;
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 16px;
margin-bottom: 32px;
font-size: 12px;
"
>
{EMAIL_OBJECT}
</h2>
{EMAIL_DESCRIPTION}
<p class="mt-auto" style="color: white; margin-top: auto; font-size: 12px">
{EMAIL_FOOTER}
</p>
</div>
<p
class="text-center text-xs"
style="
color: white;
text-align: center;
margin-left: auto;
margin-right: auto;
margin-top: 1rem;
font-size: 12px;
max-width: 50%;
"
>
{OUT_TEXT}
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,91 @@
<!doctype html>
<html>
<body>
<div
style="
background-color: #f2f5f7;
color: #242424;
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari,
'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.15008px;
line-height: 1.5;
margin: 0;
padding: 32px 0;
min-height: 100%;
width: 100%;
"
>
<table
align="center"
width="100%"
style="margin: 0 auto; max-width: 600px; background-color: #ffffff"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
>
<tbody>
<tr style="width: 100%">
<td>
<div style="padding: 24px 24px 24px 24px">
<a href="{ORIGIN_URL}" style="text-decoration: none" target="_blank"
><img
alt="{APP_NAME}"
height="24"
src="{LOGO_URL}"
style="
outline: none;
border: none;
text-decoration: none;
vertical-align: middle;
display: inline-block;
max-width: 100%;
"
/></a>
</div>
<div style="font-weight: normal; padding: 0px 24px 16px 24px">Hi {NAME}👋,</div>
<div style="font-weight: normal; padding: 0px 24px 16px 24px">
You have been invited to use our platform.
</div>
<div style="font-weight: normal; padding: 0px 24px 16px 24px">
You can setup your account clicking on this button:
</div>
<div style="padding: 16px 24px 24px 24px">
<a
href="{URL}"
style="
color: #ffffff;
font-size: 14px;
font-family: 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
font-weight: bold;
background-color: #0079cc;
display: inline-block;
padding: 12px 20px;
text-decoration: none;
"
target="_blank"
><span
><!--[if mso
]><i
style="letter-spacing: 20px; mso-font-width: -100%; mso-text-raise: 30"
hidden
>&nbsp;</i
><!
[endif]--></span
><span>Set up account</span
><span
><!--[if mso
]><i style="letter-spacing: 20px; mso-font-width: -100%" hidden>&nbsp;</i><!
[endif]--></span
></a
>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,125 @@
<!doctype html>
<html>
<body>
<div
style="
background-color: #f2f5f7;
color: #242424;
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari,
'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.15008px;
line-height: 1.5;
margin: 0;
padding: 32px 0;
min-height: 100%;
width: 100%;
"
>
<table
align="center"
width="100%"
style="margin: 0 auto; max-width: 600px; background-color: #ffffff"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
>
<tbody>
<tr style="width: 100%">
<td>
<div style="padding: 24px 24px 8px 24px; text-align: left">
<img
alt="{APPNAME}"
src="{LOGO_URL}"
height="24"
style="
height: 24px;
outline: none;
border: none;
text-decoration: none;
vertical-align: middle;
display: inline-block;
max-width: 100%;
"
/>
</div>
<h3
style="
font-weight: bold;
text-align: left;
margin: 0;
font-size: 20px;
padding: 32px 24px 0px 24px;
"
>
Reset your password?
</h3>
<div style="font-weight: normal; padding: 16px 24px 16px 24px">Hi {NAME}</div>
<div
style="
color: #474849;
font-size: 14px;
font-weight: normal;
text-align: left;
padding: 8px 24px 16px 24px;
"
>
If you didn&#x27;t request a reset, don&#x27;t worry. You can safely ignore this
email.
</div>
<div style="text-align: left; padding: 12px 24px 32px 24px">
<a
href="{URL}"
style="
color: #ffffff;
font-size: 14px;
font-family: 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
font-weight: bold;
background-color: #0068ff;
display: inline-block;
padding: 12px 20px;
text-decoration: none;
"
target="_blank"
><span
><!--[if mso
]><i
style="letter-spacing: 20px; mso-font-width: -100%; mso-text-raise: 30"
hidden
>&nbsp;</i
><!
[endif]--></span
><span>Change my password</span
><span
><!--[if mso
]><i style="letter-spacing: 20px; mso-font-width: -100%" hidden>&nbsp;</i><!
[endif]--></span
></a
>
</div>
<div style="padding: 16px 24px 16px 24px">
<hr style="width: 100%; border: none; border-top: 1px solid #eeeeee; margin: 0" />
</div>
<div
style="
color: #474849;
font-size: 12px;
font-weight: normal;
text-align: left;
padding: 4px 24px 24px 24px;
"
>
<p>
Need help? Just visit us to our
<a href="{ORIGIN_URL}" target="_blank">platform</a>.
</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -0,0 +1,93 @@
<!doctype html>
<html>
<body>
<div
style="
background-color: #f2f5f7;
color: #242424;
font-family: ui-rounded, 'Hiragino Maru Gothic ProN', Quicksand, Comfortaa, Manjari,
'Arial Rounded MT Bold', Calibri, source-sans-pro, sans-serif;
font-size: 16px;
font-weight: 400;
letter-spacing: 0.15008px;
line-height: 1.5;
margin: 0;
padding: 32px 0;
min-height: 100%;
width: 100%;
"
>
<table
align="center"
width="100%"
style="margin: 0 auto; max-width: 600px; background-color: #ffffff"
role="presentation"
cellspacing="0"
cellpadding="0"
border="0"
>
<tbody>
<tr style="width: 100%">
<td>
<div style="padding: 24px 24px 24px 24px">
<a href="{ORIGIN_URL}" style="text-decoration: none" target="_blank"
><img
alt="Snapp.li"
src="{LOGO_URL}"
width="32"
style="
width: 32px;
outline: none;
border: none;
text-decoration: none;
vertical-align: middle;
display: inline-block;
max-width: 100%;
"
/></a>
</div>
<div style="font-weight: bold; padding: 0px 24px 16px 24px">Hi {NAME}👋,</div>
<div style="font-weight: normal; padding: 0px 24px 16px 24px">
Welcome to Snapp, a simple and self hosted url shortner. You received this email
because you registered to our platform at {APPNAME}.
</div>
<div style="font-weight: normal; padding: 16px 24px 16px 24px">
If you ever need help, just visit our platform. Were here to help.
</div>
<div style="padding: 16px 24px 24px 24px">
<a
href="{URL}"
style="
color: #ffffff;
font-size: 14px;
font-family: 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
font-weight: bold;
background-color: #0079cc;
display: inline-block;
padding: 12px 20px;
text-decoration: none;
"
target="_blank"
><span
><!--[if mso
]><i
style="letter-spacing: 20px; mso-font-width: -100%; mso-text-raise: 30"
hidden
>&nbsp;</i
><!
[endif]--></span
><span>Open dashboard</span
><span
><!--[if mso
]><i style="letter-spacing: 20px; mso-font-width: -100%" hidden>&nbsp;</i><!
[endif]--></span
></a
>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -1,50 +1,15 @@
import { derived, writable, type Readable, type Writable } from 'svelte/store'; import { init, register } from 'svelte-i18n';
import { setContext, getContext } from 'svelte';
const LNG_CTX = 'LNG_CTX' as const; const defaultLocale = 'en';
type Translation = Record<string, string>; register('en', () => import('./locales/en.json'));
register('it', () => import('./locales/it.json'));
register('es', () => import('./locales/es.json'));
register('gl', () => import('./locales/gl.json'));
register('de', () => import('./locales/de.json'));
register('fr', () => import('./locales/fr.json'));
export function setLocale(localization: Translation) { init({
const locale = writable(localization); fallbackLocale: defaultLocale,
initialLocale: defaultLocale
const t = derived( });
locale,
($locale) =>
(translationKey: string, vars = {}) =>
translate(translationKey, vars, $locale)
);
setContext(LNG_CTX, { locale, t });
return { locale, t };
}
export function getLocale() {
return getContext<{
locale: Writable<Translation>;
t: Readable<(key: string, vars?: {}) => string>;
}>(LNG_CTX);
}
function translate(
translationKey: string,
vars: { [key: string]: string },
localization: Translation
) {
if (!translationKey) throw new Error('no key provided to $t()');
if (!localization || !Array.from(Object.entries(localization)).length) return translationKey;
let text = localization[translationKey];
if (!text) return translationKey;
if (vars !== undefined) {
Object.keys(vars).map((k) => {
const regex = new RegExp(`{{${k}}}`, 'g');
text = text.replace(regex, vars[k]);
});
}
return text;
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Auth",
"dashboard": "Dashboard",
"home": "Start",
"metrics": "Metriken",
"settings": "Einst.",
"users": "Benutzer"
},
"admin": {
"label": "Admin-Panel",
"helpers": {
"rpd": "Anzahl der API-Anfragen pro Tag.",
"rpm": "Anzahl der API-Anfragen pro Minute.",
"spu": "Maximale Anzahl an kurzen URLs, die ein Benutzer erstellen kann.",
"smtp": "Dieser Mailserver wird für die Passwortwiederherstellung, das Versenden von ersten Zugangseinladungen und andere von der Plattform generierte Kommunikationen verwendet.",
"watchlists": "Watchlists beeinflussen die Benutzerregistrierung und Detailaktualisierungen, während das Sperren von Domains das Kürzen von URLs verhindert.",
"add-whitelist": "Erlaube eine E-Mail oder Domain auf der gesamten Plattform.",
"add-blacklist": "Blockiere eine E-Mail oder Domain auf der gesamten Plattform.",
"vt-api": "Scannt die Domain mit der VirusTotal API. Snapp überprüft den Ruf der Domain und blockiert jede Seite mit einem negativen Wert. Positive oder neutrale Werte lassen die Domain zu. Anfragen werden 24 Stunden zwischengespeichert."
},
"labels": {
"limits": "API REST-Limits",
"smtp": "SMTP-Server",
"smtp-host": "Server",
"smtp-pass": "Passwort",
"smtp-port": "Port",
"smtp-user": "Benutzer",
"smtp-from": "Von",
"rpd": "Anfragen pro Tag",
"rpm": "Anfragen pro Minute",
"spu": "Snapp pro Benutzer",
"blacklist": "Gesperrte Entitäten",
"watchlists": "Watchlists",
"whitelist": "Erlaubte Entitäten",
"add-whitelist": "Entität erlauben",
"add-blacklist": "Entität blockieren",
"blacklisted-items": "{count} Elemente in der Sperrliste",
"whitelisted-items": "{count} Elemente in der Erlauben-Liste",
"vt-api": "VirusTotal API",
"domains": "Domains",
"emails": "E-Mails",
"usernames": "Benutzernamen"
},
"placeholders": {
"vt-api": "Geben Sie hier Ihren VTAPI-Schlüssel ein...",
"smtp-pass": "Geben Sie Ihr SMTP-Passwort ein...",
"smtp-host": "Geben Sie Ihren SMTP-Host ein...",
"smtp-from": "Geben Sie Ihre SMTP-Absenderadresse ein...",
"smtp-user": "Geben Sie Ihren SMTP-Benutzer ein...",
"smtp-port": "Geben Sie Ihren SMTP-Port ein...",
"filter-watchlist": "Entität in dieser Liste filtern..."
}
},
"settings": {
"label": {
"language": "Sprache",
"theme": "Design",
"theme-dark": "Dunkelmodus",
"theme-light": "Hellmodus",
"enable-limits": "Limits aktivieren",
"enable-signup": "Registrierungen aktivieren",
"disable-homepage": "Startseite deaktivieren",
"allow-http": "Unsicheres HTTP erlauben"
},
"saved": "Änderungen wurden gespeichert.",
"helpers": {
"signups": "Erlaube externe Registrierungen über das Front-End.",
"homepage": "Leite Benutzer stattdessen zu Anmeldeseite oder Dashboard weiter.",
"limits": "Gemeinschaftlich angeforderte API-Beschränkungen.",
"http": "Erlaube URLs mit HTTP anstelle von HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Geben Sie Ihre E-Mail-Adresse ein, um die Passwortwiederherstellung zu starten.",
"recover-password": "Dies löst eine E-Mail zur Passwortzurücksetzung an dieses Konto aus."
},
"forgot-password": "Passwort vergessen?",
"go-to-signup": "Haben Sie kein Konto? Registrieren Sie sich auf der <a class=\"link\" href=\"{url}\">Registrierungsseite</a>.",
"go-to-signin": "Haben Sie bereits ein Konto? Gehen Sie zur <a class=\"link\" href=\"{url}\">Anmeldeseite</a>.",
"sign-in": "Anmelden",
"sign-up": "Registrieren",
"recover-password": "Passwort wiederherstellen",
"reset-email-sent": "Passwortwiederherstellung gestartet, eine E-Mail wurde an die angegebene Adresse gesendet.",
"post-email-message": "Wir haben eine E-Mail an die angegebene Adresse gesendet."
},
"labels": {
"profile": "Benutzerprofil",
"create": "Benutzer einladen",
"edit": "Benutzer bearbeiten",
"list": "Liste der registrierten Benutzer"
},
"fields": {
"email": "E-Mail",
"confirm-password": "Passwort bestätigen",
"password": "Passwort",
"username": "Benutzername",
"role": "Rolle",
"max-snapps": "Max. URLs",
"notes": "Notizen"
},
"helpers": {
"invitation": "Der Benutzer erhält eine E-Mail-Einladung zur Einrichtung seines Passworts.",
"notes": "Notizen sind nur für Administratoren auf der Benutzerübersichtsseite sichtbar.",
"admin": "Wählen Sie die Berechtigungsstufe des Benutzers aus.",
"max-snapps": "Begrenzen Sie die Anzahl der Snapps, die dieser Benutzer erstellen kann.",
"password": "Das Passwort muss mindestens acht Zeichen lang sein und mindestens einen Großbuchstaben, einen Kleinbuchstaben, eine Zahl und ein Sonderzeichen enthalten."
},
"placeholders": {
"username": "Geben Sie Ihren Benutzernamen ein...",
"email": "Geben Sie Ihre E-Mail-Adresse ein...",
"password": "Geben Sie Ihr Passwort ein...",
"search": "Benutzernamen oder E-Mail suchen..."
},
"actions": {
"confirm-delete": "Diese Aktion löscht den Benutzer und seinen Inhalt dauerhaft.",
"deleted": "Benutzer wurde gelöscht.",
"edited": "Benutzerinformationen wurden gespeichert.",
"created": "Benutzer wurde erstellt.",
"logout": "Abmelden"
}
},
"tokens": {
"label": "API REST-Token",
"placeholder": "Ihr API-Schlüssel wird hier angezeigt...",
"helper": "Autorisieren Sie API-Aufrufe mit einem Authorization-Header, der das generierte Token enthält. API-Schlüssel sind einzigartig und beziehen sich auf das Benutzerkonto, das die Datenbankrollen teilt.",
"api-docs": "Siehe die vollständige <a href=\"{url}\" class=\"link\">API-Dokumentation</a> für Nutzung und Implementierungsdetails.",
"generate": "Token generieren",
"revoke": "Token widerrufen",
"copied": "Token in die Zwischenablage kopiert.",
"not-allowed-to-copy": "Der Browser-Clipboard erfordert eine sichere HTTPS-Umgebung.",
"not-found": "Token nicht gefunden.",
"fields": {
"key": "Token",
"created": "Erstellt"
}
},
"globals": {
"close": "Schließen",
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"sure-ask": "Sind Sie sicher?",
"continue": "Weiter",
"total": "Gesamt",
"max-page-reached": "Keine weiteren Elemente verfügbar.",
"save": "Speichern",
"active": "Aktiv",
"disabled": "Deaktiviert",
"back": "Zurück",
"start": "Start",
"end": "Ende",
"all": "Alle",
"world": "Welt",
"copy": "Kopie",
"loading": "Laden"
},
"metrics": {
"filters": "Filter",
"origin": "Geografische Herkunft",
"browser": "Browser",
"device": "Gerät",
"os": "Betriebssystem"
},
"migrations": {
"label": "Migration",
"import-csv": "Import aus Snapp CSV",
"export-csv": "Exportiere CSV für SNAPP",
"upload": "Datei hochladen",
"csv-file-required": "Der Import erfordert eine gültige Snapp CSV-Datei",
"success": "Snapps wurden erfolgreich importiert",
"failed": "Import fehlgeschlagen",
"requires-time": "Alle Snapps werden exportiert. Dieser Vorgang kann einige Zeit in Anspruch nehmen."
},
"snapps": {
"label": "URLs-Liste",
"placeholders": {
"original-url": "Fügen Sie Ihre ursprüngliche URL ein",
"shortcode": "Geben Sie einen benutzerdefinierten Shortcode an",
"secret": "Geben Sie ein Geheimnis zum Schutz Ihrer URL an",
"search": "Suche nach ursprünglicher URL oder Shortcode"
},
"fields": {
"original-url": "Ursprüngliche URL",
"shortcode": "Shortcode",
"has-expiration": "Ablaufdatum festlegen",
"has-secret": "Geheimnis festlegen",
"has-limited-usage": "Begrenzte Nutzung",
"secret": "Geheimnis",
"expiration": "Ablaufdatum",
"max-usages": "Max. Nutz.",
"used": "Verwendet",
"created": "Erstellt",
"hit": "Besuche",
"notes": "Notizen",
"status": "Status"
},
"labels": {
"edit": "Snapp bearbeiten",
"create": "URL kürzen",
"url-info": "URL-Informationen",
"count": "Gesamt Snapps",
"columns": "Spalten",
"open-link": "Snapp in neuem Fenster öffnen",
"details": "Snapp-Details"
},
"helpers": {
"copied-to-clipboard": "URL erfolgreich in die Zwischenablage kopiert",
"create": "Sie können eine benutzerdefinierte Kurz-URL angeben. Wenn dies nicht erfolgt, wird sie automatisch generiert.",
"original-url": "Dies muss ein gültiger <code>https://</code> Link sein.",
"shortcode": "Dieser wird generiert, wenn kein benutzerdefinierter angegeben wird.",
"secret": "Diese URL ist durch ein Geheimnis geschützt.",
"not-secret": "Diese URL ist öffentlich.",
"not-max-usages": "Keine Limits",
"provide-secret": "Bitte geben Sie das richtige Passwort ein, um fortzufahren.",
"text-pasted": "Ursprüngliche URL wurde aus der Zwischenablage eingefügt.",
"has-secret": "Geben Sie ein Geheimnis an, um die URL vor unbefugtem Zugriff zu schützen.",
"expiration": "Geben Sie ein Ablaufdatum an, um Ihre URL automatisch zu deaktivieren.",
"max-usages": "Geben Sie eine begrenzte Anzahl von Verwendungen für diesen Snapp an. Nach Erreichen des Limits wird der Snapp deaktiviert.",
"previous-expiration": "Dieser Snapp hat bereits ein Ablaufdatum, das am {relativeTime} abläuft.",
"previous-secret": "Dieser Snapp hat bereits ein Geheimnis festgelegt.",
"remove-expiration": "Ablaufdatum entfernen",
"remove-secret": "Geheimnis entfernen",
"disable-text-1": "Pause dieses Snapp. Die Daten, der ausgewählte Shortcode und die Anzeigedaten werden beibehalten. Der Benutzer erhält eine Nachricht, dass die Weiterleitung vorübergehend nicht verfügbar ist.",
"disable-text-2": "Auch wenn ein Snapp die festgelegte Anzahl von Verwendungen erreicht, wird es standardmäßig deaktiviert. Es kann von hier aus wieder aktiviert werden."
},
"actions": {
"created": "Die URL wurde erfolgreich gekürzt.",
"edited": "Der Snapp wurde aktualisiert.",
"deleted": "Der Snapp wurde gelöscht.",
"confirm-delete": "Diese Aktion wird den Snapp dauerhaft löschen."
},
"time": {
"seconds": "Sekunden",
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"weeks": "Wochen",
"months": "Monate",
"years": "Jahre"
}
},
"errors": {
"api": {
"token-missing": "Autorisierungstoken erforderlich."
},
"auth": {
"email-invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"email-registered": "Diese E-Mail-Adresse ist bereits mit einem Konto verknüpft. Versuchen Sie, sich anzumelden oder Ihr Passwort wiederherzustellen.",
"disabled-signups": "Registrierungen sind deaktiviert. Wenn Sie denken, dass dies ein Fehler ist, kontaktieren Sie bitte den Dienstadministrator.",
"password-invalid": "Das angegebene Passwort ist nicht sicher genug. Bitte versuchen Sie es erneut.",
"password-unmatch": "Passwort und Bestätigungspasswort stimmen nicht überein.",
"username-invalid": "Benutzername ist ungültig. Bitte geben Sie einen mindestens drei Zeichen langen Namen für Ihr Profil an.",
"user-already-exists": "Benutzername bereits vergeben. Bitte versuchen Sie einen anderen.",
"user-not-found": "Benutzer nicht gefunden.",
"wrong-credentials": "Falsche Anmeldedaten.",
"reset-token-expired": "Der Zurücksetzungs-Token für die Passwortwiederherstellung ist abgelaufen."
},
"snapps": {
"unallowed-not-https": "Die ursprüngliche URL muss ein sicherer https-Link sein.",
"original-url-missing": "Eine ursprüngliche URL muss für die Weiterleitung angegeben werden.",
"original-url-blacklisted": "Diese URL wurde von VirusTotal API auf die Blacklist gesetzt oder markiert.",
"missing-secret": "Sie müssen ein Geheimnis angeben, um auf diese URL zuzugreifen.",
"wrong-credentials": "Das von Ihnen angegebene Geheimnis stimmt nicht mit dem zum Entsperren der URL überein.",
"not-found": "Snapp nicht gefunden.",
"max-snapps": "Sie haben das Limit der Snapps erreicht, das ein Konto erstellen kann.",
"disabled": "Dieser Snapp ist deaktiviert, entweder vom Eigentümer oder nach Erreichen eines festgelegten Schwellenwerts. Wenn Sie denken, dass dies ein Fehler ist, wenden Sie sich bitte an den Administrator."
},
"blacklisted": {
"user": "Es gab ein Problem mit Ihrem Authentifizierungsprozess. Bitte wenden Sie sich an den Dienstadministrator.",
"domain": "Dieses Snapp leitet auf eine auf die Blacklist gesetzte Domain weiter. Bitte kontaktieren Sie den Dienstadministrator, wenn Sie denken, dass dies ein Fehler ist."
},
"generic": "Es ist ein Fehler aufgetreten. Wenn dies weiterhin besteht, wenden Sie sich bitte an den Administrator.",
"unauthorized": "Sie sind nicht berechtigt, dies zu tun.",
"label": "Fehler"
},
"homepage": {
"intro": "Wenn Sie nach einer selbstgehosteten URL-Kürzungslösung suchen, könnte Snapp genau das Richtige für Sie sein. Es ist für diejenigen gedacht, die Wert auf die Kontrolle über ihre URL-Verwaltung legen und verschiedene Technologien erkunden möchten.",
"features": {
"label": "Unsere Funktionen",
"ui": {
"label": "Benutzerfreundliche Oberfläche",
"description": "Snapp bietet eine benutzerfreundliche Oberfläche für das nahtlose Kürzen von Links. Lesen Sie, wie Sie starten können!"
},
"auth": {
"label": "Sichere Authentifizierung",
"description": "Genießen Sie eine sichere Erfahrung für Ihre Benutzersitzungen."
},
"shortcode": {
"label": "Benutzerdefinierte Kurze URLs",
"description": "Erstellen Sie personalisierte Kurzcodes für Ihre Links, um sie einprägsam und leicht teilbar zu machen."
},
"expiration": {
"label": "Ablauf Ihrer URLs",
"description": "Erstellen Sie personalisierte Kurzcodes für Ihre Links, um sie einprägsam und leicht teilbar zu machen."
},
"secrecy": {
"label": "Geschützte URLs",
"description": "Fügen Sie eine zusätzliche Schutzschicht mit geheimen Links hinzu. Wählen Sie, ob Sie Links mit einem ausgewählten Publikum unter Verwendung einzigartiger Geheimnisse teilen möchten."
},
"analytics": {
"label": "Analytics",
"description": "Machen Sie sich mit detaillierten Analysen für jeden Link, den Sie erstellen, mächtiger. Snapp sammelt anonym Metriken, liefert Einblicke in Link-Engagements und respektiert die Privatsphäre Ihrer Benutzer."
},
"umami": {
"label": "Umami-Integration",
"description": "Integrieren Sie Ihre Snapp-Instanz mit Ihrer selbst gehosteten oder Cloud-<a href={url} class=\"link\">Umami Analytics<a>-Instanz für erweiterte Metriken Ihrer Snapps."
},
"vtapi": {
"label": "VirusTotal API-Integration",
"description": "Sichern Sie die Links, die durch Ihre Snapp-Instanz gehen, durch eine Überprüfung der Reputation der <a href={url} class=\"link\">VirusTotal API</a>."
},
"rest-api": {
"label": "REST API",
"description": "Von der Community angeforderte Funktionen, die das REST-API-Endpunkt aktivieren, um Ihre Snapps aus der Ferne zu erstellen und zu verwalten. Lesen Sie die vollständige <a href={url} class=\"link\">Swagger-Dokumentation</a> hier."
}
},
"getting-started": {
"label": "Erste Schritte",
"claim": "Snapp ist eine selbst gehostete Open-Source-Plattform",
"docker": {
"label": "Docker-Container verwenden",
"helper": "Verwenden Sie diese docker-compose.yml, um Ihr Snapp auszuführen."
}
},
"migration": {
"label": "Migration",
"description": "Die neuesten Versionen von Snapp enthalten den CSV-Export, um die Migration zu erleichtern. Melden Sie sich einfach an und importieren Sie Ihre URLs über das Dashboard, und fahren Sie dort fort, wo Sie aufgehört haben."
},
"stack": {
"label": "Der Stack",
"helper": "Die beteiligte Technologie"
}
}
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Auth",
"dashboard": "Dashboard",
"home": "Home",
"metrics": "Metrics",
"settings": "Settings",
"users": "Users"
},
"admin": {
"label": "Admin Panel",
"helpers": {
"rpd": "Number of API requests per day.",
"rpm": "Number of API requests per minute.",
"spu": "Maximum number of short URLs a user can create.",
"smtp": "This mail server is used for password recovery, sending first access invitations, and other communications generated by the platform.",
"watchlists": "Watchlists affect user registration and detail updates, while banning domains will prevent URL shortening.",
"add-whitelist": "Allow an email or domain across the platform.",
"add-blacklist": "Block an email or domain across the platform.",
"vt-api": "Scans the domain using the VirusTotal API. Snapp checks the domain reputation and blocks any site with a negative score. Positive or neutral scores will allow the domain. Requests are cached for 24 hours."
},
"labels": {
"limits": "API REST Limits",
"smtp": "SMTP Server",
"smtp-host": "Server",
"smtp-pass": "Password",
"smtp-port": "Port",
"smtp-user": "User",
"smtp-from": "From",
"rpd": "Requests per Day",
"rpm": "Requests per Minute",
"spu": "Snapp per User",
"blacklist": "Blocked Entities",
"watchlists": "Watchlists",
"whitelist": "Allowed Entities",
"add-whitelist": "Allow Entity",
"add-blacklist": "Block Entity",
"blacklisted-items": "{count} items in blocked list",
"whitelisted-items": "{count} items in allowed list",
"vt-api": "VirusTotal API",
"domains": "Domains",
"emails": "Emails",
"usernames": "Usernames"
},
"placeholders": {
"vt-api": "Enter your VTAPI Key here...",
"smtp-pass": "Enter your SMTP Password...",
"smtp-host": "Enter your SMTP Host...",
"smtp-from": "Enter your SMTP From address...",
"smtp-user": "Enter your SMTP User...",
"smtp-port": "Enter your SMTP Port...",
"filter-watchlist": "Filter entity in this list..."
}
},
"settings": {
"label": {
"language": "Language",
"theme": "Theme",
"theme-dark": "Dark Mode",
"theme-light": "Light Mode",
"enable-limits": "Enable Limits",
"enable-signup": "Enable Signups",
"disable-homepage": "Disable Homepage",
"allow-http": "Allow Unsecure HTTP"
},
"saved": "Changes have been saved.",
"helpers": {
"signups": "Allow external signups from the front-end.",
"homepage": "Redirect users to login or dashboard instead.",
"limits": "Community-requested API limitations.",
"http": "Allow URLs with HTTP instead of HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Enter your email to start password recovery.",
"recover-password": "This will trigger a password reset email to this account."
},
"forgot-password": "Forgot your password?",
"go-to-signup": "Don't have an account? Register at the <a class=\"link\" href=\"{url}\">signup page</a>.",
"go-to-signin": "Already have an account? Go to the <a class=\"link\" href=\"{url}\">login page</a>.",
"sign-in": "Sign in",
"sign-up": "Sign up",
"recover-password": "Recover Password",
"reset-email-sent": "Password recovery started, an email has been sent to the address provided.",
"post-email-message": "We sent an email to the address you provided."
},
"labels": {
"profile": "User Profile",
"create": "Invite User",
"edit": "Edit User",
"list": "List of Registered Users"
},
"fields": {
"email": "Email",
"confirm-password": "Confirm Password",
"password": "Password",
"username": "Username",
"role": "Role",
"max-snapps": "Max URLs",
"notes": "Notes"
},
"helpers": {
"invitation": "The user will receive an email invitation to set up their password.",
"notes": "Notes are visible to admins only in the user overview page.",
"admin": "Select user authorization level.",
"max-snapps": "Limit the number of snapps this user can create.",
"password": "Password must be at least eight characters long, with at least one uppercase letter, one lowercase letter, one number, and one special character."
},
"placeholders": {
"username": "Type your username...",
"email": "Type your email address...",
"password": "Type your password...",
"search": "Search username or email..."
},
"actions": {
"confirm-delete": "This action will delete the user and their content permanently.",
"deleted": "User has been deleted.",
"edited": "User information has been saved.",
"created": "User has been created.",
"logout": "Sign out"
}
},
"tokens": {
"label": "API REST Token",
"placeholder": "Your API Key will appear here...",
"helper": "Authorize API calls with an Authorization Header bearing the generated Token. API Keys are unique and relate to the user account, sharing database roles.",
"api-docs": "Refer to the complete <a href=\"{url}\" class=\"link\">API Docs</a> for usage and implementation details.",
"generate": "Generate Token",
"revoke": "Revoke Token",
"copied": "Token copied to clipboard.",
"not-allowed-to-copy": "Browser Clipboard requires a secure HTTPS environment.",
"not-found": "Token not found.",
"fields": {
"key": "Token",
"created": "Created"
}
},
"globals": {
"close": "Close",
"confirm": "Confirm",
"cancel": "Cancel",
"sure-ask": "Are you sure?",
"continue": "Continue",
"total": "Total",
"max-page-reached": "No more items available.",
"save": "Save",
"active": "Active",
"disabled": "Disabled",
"back": "Back",
"start": "Start",
"end": "End",
"all": "All",
"world": "World",
"copy": "Copy",
"loading":"Loading"
},
"metrics": {
"filters": "Filters",
"origin": "Geographical Origin",
"browser": "Browser",
"device": "Device",
"os": "Operating System"
},
"migrations": {
"label": "Migration",
"import-csv": "Import from Snapp CSV",
"export-csv": "Export CSV for SNAPP",
"upload": "Upload file",
"csv-file-required": "Import requires a valid Snapp CSV file",
"success": "Snapps imported correctly",
"failed": "Failed import",
"requires-time": "All the snapps will be exported. This action may requires some time."
},
"snapps": {
"label": "URLs list",
"placeholders": {
"original-url": "Paste your original url",
"shortcode": "Specify a custom shortcode",
"secret": "Specify a secret to protect your url",
"search": "Search by original url or shortcode"
},
"fields": {
"original-url": "Original URL",
"shortcode": "Shortcode",
"has-expiration": "Set an expiration",
"has-secret": "Set a Secret",
"has-limited-usage": "Has limited usage",
"secret": "Secret",
"expiration": "Expiraton",
"max-usages": "Max Usages",
"used": "Used",
"created": "Created",
"hit": "Visits",
"notes": "Notes",
"status": "Status"
},
"labels": {
"edit": "Edit Snapp",
"create": "Shorten an URL",
"url-info": "URL Information",
"count": "Total snapps",
"columns": "Columns",
"open-link": "Open Snapp in a new window",
"details": "Snapp Details"
},
"helpers": {
"create": "You can specify a custom short-url. If left empty it will be autogenerated",
"original-url": "This must be a valid <code>https://</code> link",
"shortcode": "This will be generated if a custom is not specified",
"secret": "This URL is protected by a secret",
"not-secret": "This URL is public",
"not-max-usages": "No Limits",
"provide-secret": "Please provide the correct password to continue",
"text-pasted": "Original URL has been pasted from clipboard",
"has-secret": "Specify a Secret to protect the URL from unwanted access",
"expiration": "Specify an expiration date to auto-disable your url",
"max-usages": "Specify a limited amount of usages for this snapp. After reaching the limit the snapp will be disabled",
"previous-expiration": "This Snapp has already an expiration set, expiring {relativeTime}",
"previous-secret": "This Snapp has already a secret set",
"remove-expiration": "Remove expiration",
"remove-secret": "Remove secret",
"disable-text-1": "Pause this snapp. Its data, the selected short code, and display data will be preserved. The user will receive a disambiguation message explaining that the redirect is temporarily unavailable.",
"disable-text-2": "Also when a snapp get used for the set number of times, it get disabled by default. It can be re-enabled from here.",
"copied-to-clipboard": "URL successfully copied to clipboard"
},
"actions": {
"created": "The URL have been successfully Snapp'd",
"edited": "The Snapp has been updated",
"deleted": "The Snapp has been deleted",
"confirm-delete": "This action will delete the snapp permanently."
},
"time": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
}
},
"errors": {
"api": {
"token-missing": "Authorization token required."
},
"auth": {
"email-invalid": "Please provide a valid email address.",
"email-registered": "This email is already associated with an account. Try signing up or recovering your password.",
"disabled-signups": "Signups are disabled. If you think this is a mistake, please contact the service administrator.",
"password-invalid": "The password provided is not secure enough. Please try again.",
"password-unmatch": "Password and confirmation password do not match.",
"username-invalid": "Username is invalid. Please provide at least a three-character long name for your profile.",
"user-already-exists": "Username already taken. Please try another one.",
"user-not-found": "User not found.",
"wrong-credentials": "Incorrect credentials.",
"reset-token-expired": "Password recovery reset token has expired."
},
"snapps": {
"unallowed-not-https": "The original URL must be a secure https link",
"original-url-missing": "An Original URL must be provided for redirection",
"original-url-blacklisted": "This URL has been blacklisted or flagged by VirusTotal API",
"missing-secret": "You must provide a secret to access this URL",
"wrong-credentials": "The secret you provide doesn't match the one to unlock the URL",
"not-found": "Snapp not found",
"max-snapps": "You reached the limit of snapp that an account can create",
"disabled": "This Snapp is disabled, by the owner or after reaching a set threeshold. If you think this is a mistake try contact administrator"
},
"blacklisted": {
"user": "There was an issue with your authentication process. Please contact the service administrator.",
"domain": "This snapp redirects to a blacklisted domain. Please contact the service administrator if you think this is a mistake."
},
"generic": "An error occurred. If this persists, contact the administrator.",
"unauthorized": "You are not allowed to do this",
"label": "Error"
},
"homepage": {
"intro": "Looking for a reliable, self-hostable URL shortening solution? Look no further! Snapp is the perfect tool for those who seek control over their URL management.",
"features": {
"label": "Our Features",
"ui": {
"label": "User Friendly Interface",
"description": "Snapp provides a user-friendly interface for seamless link shortening. Read how to get started!"
},
"auth": {
"label": "Secure Authentication",
"description": "Enjoy a secure experience for your user sessions."
},
"shortcode": {
"label": "Custom Short URLs",
"description": "Create personalized short codes for your links to make them memorable and easy to share."
},
"expiration": {
"label": "Expire your URLs",
"description": "Create personalized short codes for your links to make them memorable and easy to share."
},
"secrecy": {
"label": "Protected URLs",
"description": "Add an extra layer of protection with secret links. Choose to share links with a selected audience using unique secrets."
},
"analytics": {
"label": "Analytics",
"description": "Empower yourself with detailed analytics for every link you create. Snapp gathers metrics anonymously, providing insights into link engagements and respecting your users privacy."
},
"umami": {
"label": "Umami Integration",
"description": "Integrate your Snapp Instance with your self-hosted or cloud <a href={url} class=\"link\">Umami Analytics<a> instance for advanced metrics of your Snapps."
},
"vtapi": {
"label": "VirusTotal API Integration",
"description": "Secure the links passing through your Snapp instance with a check on <a href={url} class=\"link\">VirusTotal API</a> reputation."
},
"rest-api": {
"label": "REST API",
"description": "Community requested features that enables API Rest endpoint to create and manage your Snapps remotely. Read full <a href={url} class=\"link\">Swagger documentation</a> here"
}
},
"getting-started": {
"label": "Getting started",
"claim": "Snapp is a self-hostable open source platform",
"docker": {
"label": "Using Docker Container",
"helper": "Use this docker-compose.yml to run your Snapp"
}
},
"migration": {
"label": "Migration",
"description": "Latest versions of Snapp included CSV Export in order to facilitate migration. Simply login and import your urls from dashboard, and continue from where you left."
},
"stack": {
"label": "The Stack",
"helper": "The technology involved"
}
}
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Autenticación",
"dashboard": "Dashboard",
"home": "Inicio",
"metrics": "Métricas",
"settings": "Configurar",
"users": "Usuarios"
},
"admin": {
"label": "Admin Panel",
"helpers": {
"rpd": "Número de solicitudes API por día.",
"rpm": "Número de solicitudes API por minuto.",
"spu": "Número máximo de URLs cortas que un usuario puede crear.",
"smtp": "Este servidor de correo electrónico se utiliza para la recuperación de contraseñas, el envío de invitaciones de acceso inicial y otras comunicaciones generadas por la plataforma.",
"watchlists": "Las watchlists afectan la inscripción de usuarios y actualizaciones de detalles, mientras que bloquear dominios impedirá el encurtamiento de URLs.",
"add-whitelist": "Permitir un correo electrónico o dominio a través de la plataforma.",
"add-blacklist": "Bloquear un correo electrónico o dominio a través de la plataforma.",
"vt-api": "Escanea el dominio utilizando la API de VirusTotal. Snapp verifica la reputación del dominio y bloquea cualquier sitio con un puntaje negativo. Los puntajes positivos o neutrales permitirán el dominio. Las solicitudes se cachean durante 24 horas."
},
"labels": {
"limits": "Límites de la API REST",
"smtp": "Servidor SMTP",
"smtp-host": "Servidor",
"smtp-pass": "Contraseña",
"smtp-port": "Puerto",
"smtp-user": "Usuario",
"smtp-from": "De",
"rpd": "Solicitudes por Día",
"rpm": "Solicitudes por Minuto",
"spu": "Snapp por Usuario",
"blacklist": "Entidades Bloqueadas",
"watchlists": "Listas de Vigilancia",
"whitelist": "Entidades Permitidas",
"add-whitelist": "Permitir Entidad",
"add-blacklist": "Bloquear Entidad",
"blacklisted-items": "{count} elementos en la lista bloqueada",
"whitelisted-items": "{count} elementos en la lista permitida",
"vt-api": "API de VirusTotal",
"domains": "Dominios",
"emails": "Correos Electrónicos",
"usernames": "Nombres de Usuario"
},
"placeholders": {
"vt-api": "Introduce tu clave de VTAPI aquí...",
"smtp-pass": "Introduce tu contraseña SMTP...",
"smtp-host": "Introduce tu servidor SMTP...",
"smtp-from": "Introduce tu dirección de SMTP From...",
"smtp-user": "Introduce tu usuario SMTP...",
"smtp-port": "Introduce tu puerto SMTP...",
"filter-watchlist": "Filtrar entidad en esta lista..."
}
},
"settings": {
"label": {
"language": "Idioma",
"theme": "Tema",
"theme-dark": "Modo Oscuro",
"theme-light": "Modo Claro",
"enable-limits": "Habilitar Límites",
"enable-signup": "Habilitar Registros",
"disable-homepage": "Deshabilitar Página de Inicio",
"allow-http": "Permitir HTTP No Seguro"
},
"saved": "Los cambios han sido guardados.",
"helpers": {
"signups": "Permitir registros externos desde el front-end.",
"homepage": "Redirigir a los usuarios al inicio de sesión o al panel en su lugar.",
"limits": "Limitaciones de API solicitadas por la comunidad.",
"http": "Permitir URLs con HTTP en lugar de HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Introduce tu correo electrónico para comenzar la recuperación de contraseña.",
"recover-password": "Esto enviará un correo electrónico de restablecimiento de contraseña a esta cuenta."
},
"forgot-password": "¿Olvidaste tu contraseña?",
"go-to-signup": "¿No tienes una cuenta? Regístrate en la <a class=\"link\" href=\"{url}\">página de registro</a>.",
"go-to-signin": "¿Ya tienes una cuenta? Ve a la <a class=\"link\" href=\"{url}\">página de inicio de sesión</a>.",
"sign-in": "Iniciar sesión",
"sign-up": "Registrarse",
"recover-password": "Recuperar Contraseña",
"reset-email-sent": "La recuperación de contraseña ha comenzado, se ha enviado un correo electrónico a la dirección proporcionada.",
"post-email-message": "Hemos enviado un correo electrónico a la dirección que proporcionaste."
},
"labels": {
"profile": "Perfil de Usuario",
"create": "Invitar Usuario",
"edit": "Editar Usuario",
"list": "Lista de Usuarios Registrados"
},
"fields": {
"email": "Correo Electrónico",
"confirm-password": "Confirmar Contraseña",
"password": "Contraseña",
"username": "Nombre de Usuario",
"role": "Rol",
"max-snapps": "Máx. URLs",
"notes": "Notas"
},
"helpers": {
"invitation": "El usuario recibirá una invitación por correo electrónico para configurar su contraseña.",
"notes": "Las notas son visibles solo para los administradores en la página de resumen del usuario.",
"admin": "Selecciona el nivel de autorización del usuario.",
"max-snapps": "Limita el número de snapps que este usuario puede crear.",
"password": "La contraseña debe tener al menos ocho caracteres, incluyendo al menos una letra mayúscula, una letra minúscula, un número y un carácter especial."
},
"placeholders": {
"username": "Escribe tu nombre de usuario...",
"email": "Escribe tu dirección de correo electrónico...",
"password": "Escribe tu contraseña...",
"search": "Buscar nombre de usuario o correo electrónico..."
},
"actions": {
"confirm-delete": "Esta acción eliminará al usuario y su contenido permanentemente.",
"deleted": "El usuario ha sido eliminado.",
"edited": "La información del usuario ha sido guardada.",
"created": "El usuario ha sido creado.",
"logout": "Cerrar sesión"
}
},
"tokens": {
"label": "Token de API REST",
"placeholder": "Tu clave de API aparecerá aquí...",
"helper": "Autoriza las llamadas a la API con un encabezado de autorización que contenga el Token generado. Las claves de API son únicas y están relacionadas con la cuenta de usuario, compartiendo roles de base de datos.",
"api-docs": "Consulta la <a href=\"{url}\" class=\"link\">documentación completa de la API</a> para detalles sobre el uso y la implementación.",
"generate": "Generar Token",
"revoke": "Revocar Token",
"copied": "Token copiado al portapapeles.",
"not-allowed-to-copy": "El portapapeles del navegador requiere un entorno HTTPS seguro.",
"not-found": "Token no encontrado.",
"fields": {
"key": "Token",
"created": "Creado"
}
},
"globals": {
"close": "Cerrar",
"confirm": "Confirmar",
"cancel": "Cancelar",
"sure-ask": "¿Estás seguro?",
"continue": "Continuar",
"total": "Total",
"max-page-reached": "No hay más elementos disponibles.",
"save": "Guardar",
"active": "Activo",
"disabled": "Desactivado",
"back": "Volver",
"start": "Iniciar",
"end": "Finalizar",
"all": "Todos",
"world": "Mundo",
"copy": "Copia",
"loading": "Cargando"
},
"metrics": {
"filters": "Filtros",
"origin": "Origen Geográfico",
"browser": "Navegador",
"device": "Dispositivo",
"os": "Sistema Operativo"
},
"migrations": {
"label": "Migración",
"import-csv": "Importar desde Snapp CSV",
"export-csv": "Exportar CSV para SNAPP",
"upload": "Subir archivo",
"csv-file-required": "La importación requiere un archivo CSV válido de Snapp",
"success": "Snapps importados correctamente",
"failed": "Importación fallida",
"requires-time": "Se exportarán todos los snapps. Esta acción puede requerir algo de tiempo."
},
"snapps": {
"label": "Lista de URLs",
"placeholders": {
"original-url": "Pega tu URL original",
"shortcode": "Especifica un código corto personalizado",
"secret": "Especifica un secreto para proteger tu URL",
"search": "Buscar por URL original o código corto"
},
"fields": {
"original-url": "URL Original",
"shortcode": "Código Corto",
"has-expiration": "Establecer una expiración",
"has-secret": "Establecer un Secreto",
"has-limited-usage": "Tiene uso limitado",
"secret": "Secreto",
"expiration": "Expiración",
"max-usages": "Usos Máximos",
"used": "Usado",
"created": "Creado",
"hit": "Visitas",
"notes": "Notas",
"status": "Estado"
},
"labels": {
"edit": "Editar Snapp",
"create": "Acortar una URL",
"url-info": "Información de la URL",
"count": "Total de snapps",
"columns": "Columnas",
"open-link": "Abrir Snapp en una nueva ventana",
"details": "Detalles del Snapp"
},
"helpers": {
"create": "Puedes especificar una URL corta personalizada. Si se deja en blanco, se generará automáticamente.",
"original-url": "Debe ser un enlace válido <code>https://</code>",
"shortcode": "Se generará si no se especifica uno personalizado",
"secret": "Esta URL está protegida por un secreto",
"not-secret": "Esta URL es pública",
"not-max-usages": "Sin Límites",
"provide-secret": "Por favor, proporciona la contraseña correcta para continuar",
"text-pasted": "La URL original ha sido pegada desde el portapapeles",
"has-secret": "Especifica un Secreto para proteger la URL de accesos no deseados",
"expiration": "Especifica una fecha de expiración para desactivar automáticamente tu URL",
"max-usages": "Especifica una cantidad limitada de usos para este snapp. Después de alcanzar el límite, el snapp será desactivado",
"previous-expiration": "Este Snapp ya tiene una expiración establecida, expirando en {relativeTime}",
"previous-secret": "Este Snapp ya tiene un secreto establecido",
"remove-expiration": "Eliminar expiración",
"remove-secret": "Eliminar secreto",
"disable-text-1": "Pausar este snapp. Sus datos, el código corto seleccionado y los datos de visualización serán preservados. El usuario recibirá un mensaje de desambiguación explicando que el redireccionamiento está temporalmente no disponible.",
"disable-text-2": "Además, cuando un snapp se use el número establecido de veces, se desactiva por defecto. Puede ser reactivado desde aquí.",
"copied-to-clipboard": "URL copiada correctamente al portapapeles"
},
"actions": {
"created": "La URL ha sido acortada exitosamente",
"edited": "El Snapp ha sido actualizado",
"deleted": "El Snapp ha sido eliminado",
"confirm-delete": "Esta acción eliminará el snapp permanentemente."
},
"time": {
"seconds": "Segundos",
"minutes": "Minutos",
"hours": "Horas",
"days": "Días",
"weeks": "Semanas",
"months": "Meses",
"years": "Años"
}
},
"errors": {
"api": {
"token-missing": "Se requiere un token de autorización."
},
"auth": {
"email-invalid": "Por favor, proporciona una dirección de correo electrónico válida.",
"email-registered": "Este correo electrónico ya está asociado con una cuenta. Intenta registrarte o recuperar tu contraseña.",
"disabled-signups": "Los registros están desactivados. Si crees que esto es un error, contacta al administrador del servicio.",
"password-invalid": "La contraseña proporcionada no es lo suficientemente segura. Por favor, intenta de nuevo.",
"password-unmatch": "La contraseña y la confirmación de la contraseña no coinciden.",
"username-invalid": "El nombre de usuario es inválido. Por favor, proporciona un nombre de al menos tres caracteres para tu perfil.",
"user-already-exists": "El nombre de usuario ya está en uso. Por favor, prueba con otro.",
"user-not-found": "Usuario no encontrado.",
"wrong-credentials": "Credenciales incorrectas.",
"reset-token-expired": "El token de recuperación de contraseña ha expirado."
},
"snapps": {
"unallowed-not-https": "La URL original debe ser un enlace seguro https",
"original-url-missing": "Debe proporcionarse una URL original para la redirección",
"original-url-blacklisted": "Esta URL ha sido bloqueada o marcada por la API de VirusTotal",
"missing-secret": "Debes proporcionar un secreto para acceder a esta URL",
"wrong-credentials": "El secreto proporcionado no coincide con el que desbloquea la URL",
"not-found": "Snapp no encontrado",
"max-snapps": "Has alcanzado el límite de snapps que una cuenta puede crear",
"disabled": "Este Snapp está desactivado, ya sea por el propietario o después de alcanzar un umbral establecido. Si crees que esto es un error, intenta contactar al administrador"
},
"blacklisted": {
"user": "Hubo un problema con tu proceso de autenticación. Por favor, contacta al administrador del servicio.",
"domain": "Este snapp redirige a un dominio bloqueado. Por favor, contacta al administrador del servicio si crees que esto es un error."
},
"generic": "Ocurrió un error. Si esto persiste, contacta al administrador.",
"unauthorized": "No tienes permiso para hacer esto",
"label": "Error"
},
"homepage": {
"intro": "Si estás buscando una solución de acortamiento de URL autogestionada, Snapp podría ser lo que necesitas. Está diseñado para aquellos que valoran el control sobre la gestión de sus URL y quieren explorar diversas tecnologías.",
"features": {
"label": "Nuestras Funcionalidades",
"ui": {
"label": "Interfaz Amigable",
"description": "Snapp proporciona una interfaz amigable para un acortamiento de enlaces sin problemas. ¡Lee cómo comenzar!"
},
"auth": {
"label": "Autenticación Segura",
"description": "Disfruta de una experiencia segura para tus sesiones de usuario."
},
"shortcode": {
"label": "URLs Cortas Personalizadas",
"description": "Crea códigos cortos personalizados para tus enlaces para que sean memorables y fáciles de compartir."
},
"expiration": {
"label": "Caducidad de tus URLs",
"description": "Crea códigos cortos personalizados para tus enlaces para que sean memorables y fáciles de compartir."
},
"secrecy": {
"label": "URLs Protegidas",
"description": "Añade una capa adicional de protección con enlaces secretos. Elige compartir enlaces con una audiencia seleccionada usando secretos únicos."
},
"analytics": {
"label": "Analítica",
"description": "Empodérate con análisis detallados para cada enlace que creas. Snapp recopila métricas de manera anónima, proporcionando información sobre el compromiso con los enlaces y respetando la privacidad de tus usuarios."
},
"umami": {
"label": "Integración de Umami",
"description": "Integra tu instancia de Snapp con tu instancia de <a href={url} class=\"link\">Umami Analytics<a> auto-hospedada o en la nube para métricas avanzadas de tus Snapps."
},
"vtapi": {
"label": "Integración de la API VirusTotal",
"description": "Asegura los enlaces que pasan por tu instancia de Snapp con una verificación de la reputación de la <a href={url} class=\"link\">API VirusTotal</a>."
},
"rest-api": {
"label": "REST API",
"description": "Funciones solicitadas por la comunidad que permiten al endpoint REST API crear y gestionar tus Snapps de forma remota. Lee la <a href={url} class=\"link\">documentación Swagger</a> completa aquí."
}
},
"getting-started": {
"label": "Comenzar",
"claim": "Snapp es una plataforma de código abierto auto-hospedada",
"docker": {
"label": "Uso del Contenedor Docker",
"helper": "Usa este docker-compose.yml para ejecutar tu Snapp."
}
},
"migration": {
"label": "Migración",
"description": "Las últimas versiones de Snapp incluyen exportación CSV para facilitar la migración. Simplemente inicia sesión e importa tus URLs desde el panel de control, y continúa desde donde lo dejaste."
},
"stack": {
"label": "La Stack",
"helper": "La tecnología involucrada"
}
}
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Auth",
"dashboard": "Tableau",
"home": "Accueil",
"metrics": "Stats",
"settings": "Param.",
"users": "Util."
},
"admin": {
"label": "Panneau d'administration",
"helpers": {
"rpd": "Nombre de requêtes API par jour.",
"rpm": "Nombre de requêtes API par minute.",
"spu": "Nombre maximum de courtes URL qu'un utilisateur peut créer.",
"smtp": "Ce serveur de messagerie est utilisé pour la récupération de mot de passe, l'envoi des invitations de premier accès et d'autres communications générées par la plateforme.",
"watchlists": "Les listes de surveillance affectent l'enregistrement des utilisateurs et les mises à jour de détails, tandis que le blocage de domaines empêchera le raccourcissement d'URL.",
"add-whitelist": "Autoriser un e-mail ou un domaine sur l'ensemble de la plateforme.",
"add-blacklist": "Bloquer un e-mail ou un domaine sur l'ensemble de la plateforme.",
"vt-api": "Analyse le domaine à l'aide de l'API VirusTotal. Snapp vérifie la réputation du domaine et bloque tout site avec un score négatif. Les scores positifs ou neutres autoriseront le domaine. Les requêtes sont mises en cache pendant 24 heures."
},
"labels": {
"limits": "Limites API REST",
"smtp": "Serveur SMTP",
"smtp-host": "Serveur",
"smtp-pass": "Mot de passe",
"smtp-port": "Port",
"smtp-user": "Utilisateur",
"smtp-from": "De",
"rpd": "Requêtes par jour",
"rpm": "Requêtes par minute",
"spu": "Snapp par utilisateur",
"blacklist": "Entités bloquées",
"watchlists": "Listes de surveillance",
"whitelist": "Entités autorisées",
"add-whitelist": "Autoriser l'entité",
"add-blacklist": "Bloquer l'entité",
"blacklisted-items": "{count} éléments dans la liste bloquée",
"whitelisted-items": "{count} éléments dans la liste autorisée",
"vt-api": "API VirusTotal",
"domains": "Domaines",
"emails": "E-mails",
"usernames": "Noms d'utilisateur"
},
"placeholders": {
"vt-api": "Entrez votre clé VTAPI ici...",
"smtp-pass": "Entrez votre mot de passe SMTP...",
"smtp-host": "Entrez votre hôte SMTP...",
"smtp-from": "Entrez votre adresse SMTP de...",
"smtp-user": "Entrez votre utilisateur SMTP...",
"smtp-port": "Entrez votre port SMTP...",
"filter-watchlist": "Filtrer l'entité dans cette liste..."
}
},
"settings": {
"label": {
"language": "Langue",
"theme": "Thème",
"theme-dark": "Mode sombre",
"theme-light": "Mode clair",
"enable-limits": "Activer les limites",
"enable-signup": "Activer les inscriptions",
"disable-homepage": "Désactiver la page d'accueil",
"allow-http": "Autoriser HTTP non sécurisé"
},
"saved": "Les modifications ont été enregistrées.",
"helpers": {
"signups": "Autoriser les inscriptions externes depuis le front-end.",
"homepage": "Rediriger les utilisateurs vers la connexion ou le tableau de bord à la place.",
"limits": "Limites API demandées par la communauté.",
"http": "Autoriser les URL avec HTTP au lieu de HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Entrez votre e-mail pour commencer la récupération de mot de passe.",
"recover-password": "Cela déclenchera un e-mail de réinitialisation de mot de passe pour ce compte."
},
"forgot-password": "Mot de passe oublié ?",
"go-to-signup": "Vous n'avez pas de compte ? Inscrivez-vous sur la <a class=\"link\" href=\"{url}\">page d'inscription</a>.",
"go-to-signin": "Vous avez déjà un compte ? Allez sur la <a class=\"link\" href=\"{url}\">page de connexion</a>.",
"sign-in": "Se connecter",
"sign-up": "S'inscrire",
"recover-password": "Récupérer le mot de passe",
"reset-email-sent": "La récupération de mot de passe a commencé, un e-mail a été envoyé à l'adresse fournie.",
"post-email-message": "Nous avons envoyé un e-mail à l'adresse que vous avez fournie."
},
"labels": {
"profile": "Profil utilisateur",
"create": "Inviter un utilisateur",
"edit": "Modifier l'utilisateur",
"list": "Liste des utilisateurs enregistrés"
},
"fields": {
"email": "E-mail",
"confirm-password": "Confirmer le mot de passe",
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"role": "Rôle",
"max-snapps": "Max URLs",
"notes": "Notes"
},
"helpers": {
"invitation": "L'utilisateur recevra une invitation par e-mail pour configurer son mot de passe.",
"notes": "Les notes sont visibles uniquement par les administrateurs dans la page de vue d'ensemble de l'utilisateur.",
"admin": "Sélectionner le niveau d'autorisation de l'utilisateur.",
"max-snapps": "Limiter le nombre de snapps que cet utilisateur peut créer.",
"password": "Le mot de passe doit comporter au moins huit caractères, avec au moins une lettre majuscule, une lettre minuscule, un chiffre et un caractère spécial."
},
"placeholders": {
"username": "Tapez votre nom d'utilisateur...",
"email": "Tapez votre adresse e-mail...",
"password": "Tapez votre mot de passe...",
"search": "Rechercher un nom d'utilisateur ou un e-mail..."
},
"actions": {
"confirm-delete": "Cette action supprimera l'utilisateur et son contenu de manière permanente.",
"deleted": "L'utilisateur a été supprimé.",
"edited": "Les informations de l'utilisateur ont été enregistrées.",
"created": "L'utilisateur a été créé.",
"logout": "Se déconnecter"
}
},
"tokens": {
"label": "Jeton API REST",
"placeholder": "Votre clé API apparaîtra ici...",
"helper": "Autorisez les appels API avec un en-tête d'autorisation portant le jeton généré. Les clés API sont uniques et se rapportent au compte utilisateur, partageant des rôles dans la base de données.",
"api-docs": "Consultez la <a href=\"{url}\" class=\"link\">documentation API</a> complète pour les détails d'utilisation et d'implémentation.",
"generate": "Générer le jeton",
"revoke": "Révoquer le jeton",
"copied": "Jeton copié dans le presse-papiers.",
"not-allowed-to-copy": "Le presse-papiers du navigateur nécessite un environnement HTTPS sécurisé.",
"not-found": "Jeton non trouvé.",
"fields": {
"key": "Jeton",
"created": "Créé"
}
},
"globals": {
"close": "Fermer",
"confirm": "Confirmer",
"cancel": "Annuler",
"sure-ask": "Êtes-vous sûr ?",
"continue": "Continuer",
"total": "Total",
"max-page-reached": "Plus d'articles disponibles.",
"save": "Enregistrer",
"active": "Actif",
"disabled": "Désactivé",
"back": "Retour",
"start": "Début",
"end": "Fin",
"all": "Tout",
"world": "Monde",
"copy": "Copie",
"loading": "Chargement"
},
"metrics": {
"filters": "Filtres",
"origin": "Origine géographique",
"browser": "Navigateur",
"device": "Appareil",
"os": "Système d'exploitation"
},
"migrations": {
"label": "Migration",
"import-csv": "Importer depuis Snapp CSV",
"export-csv": "Exporter CSV pour SNAPP",
"upload": "Téléverser un fichier",
"csv-file-required": "L'importation nécessite un fichier Snapp CSV valide",
"success": "Snapps importés correctement",
"failed": "Importation échouée",
"requires-time": "Tous les snapps seront exportés. Cette action peut prendre un certain temps."
},
"snapps": {
"label": "Liste des URLs",
"placeholders": {
"original-url": "Collez votre URL originale",
"shortcode": "Spécifiez un shortcode personnalisé",
"secret": "Spécifiez un secret pour protéger votre URL",
"search": "Rechercher par URL originale ou shortcode"
},
"fields": {
"original-url": "URL originale",
"shortcode": "Shortcode",
"has-expiration": "Définir une expiration",
"has-secret": "Définir un secret",
"has-limited-usage": "Usage limité",
"secret": "Secret",
"expiration": "Expiration",
"max-usages": "Utilisations max",
"used": "Utilisé",
"created": "Créé",
"hit": "Visites",
"notes": "Notes",
"status": "Statut"
},
"labels": {
"edit": "Modifier Snapp",
"create": "Raccourcir une URL",
"url-info": "Informations sur l'URL",
"count": "Total des snapps",
"columns": "Colonnes",
"open-link": "Ouvrir Snapp dans une nouvelle fenêtre",
"details": "Détails du Snapp"
},
"helpers": {
"create": "Vous pouvez spécifier une URL courte personnalisée. Si elle est laissée vide, elle sera générée automatiquement",
"original-url": "Cela doit être un lien valide <code>https://</code>",
"shortcode": "Cela sera généré si un personnalisé n'est pas spécifié",
"secret": "Cette URL est protégée par un secret",
"not-secret": "Cette URL est publique",
"not-max-usages": "Pas de limites",
"provide-secret": "Veuillez fournir le mot de passe correct pour continuer",
"text-pasted": "L'URL originale a été collée depuis le presse-papiers",
"has-secret": "Spécifiez un secret pour protéger l'URL contre les accès non désirés",
"expiration": "Spécifiez une date d'expiration pour désactiver automatiquement votre URL",
"max-usages": "Spécifiez une quantité limitée d'utilisations pour ce snapp. Après avoir atteint la limite, le snapp sera désactivé",
"previous-expiration": "Ce Snapp a déjà une expiration définie, expirant {relativeTime}",
"previous-secret": "Ce Snapp a déjà un secret défini",
"remove-expiration": "Supprimer l'expiration",
"remove-secret": "Supprimer le secret",
"disable-text-1": "Suspendre ce snapp. Ses données, le code court sélectionné et les données d'affichage seront conservés. L'utilisateur recevra un message de clarification expliquant que la redirection est temporairement indisponible.",
"disable-text-2": "Aussi, lorsqu'un snapp est utilisé pour le nombre d'occurrences défini, il est désactivé par défaut. Il peut être réactivé depuis ici.",
"copied-to-clipboard": "URL copiée correctement dans le presse-papiers"
},
"actions": {
"created": "L'URL a été Snappée avec succès",
"edited": "Le Snapp a été mis à jour",
"deleted": "Le Snapp a été supprimé",
"confirm-delete": "Cette action supprimera le snapp définitivement."
},
"time": {
"seconds": "Secondes",
"minutes": "Minutes",
"hours": "Heures",
"days": "Jours",
"weeks": "Semaines",
"months": "Mois",
"years": "Années"
}
},
"errors": {
"api": {
"token-missing": "Jeton d'autorisation requis."
},
"auth": {
"email-invalid": "Veuillez fournir une adresse e-mail valide.",
"email-registered": "Cet e-mail est déjà associé à un compte. Essayez de vous inscrire ou de récupérer votre mot de passe.",
"disabled-signups": "Les inscriptions sont désactivées. Si vous pensez qu'il s'agit d'une erreur, veuillez contacter l'administrateur du service.",
"password-invalid": "Le mot de passe fourni n'est pas suffisamment sécurisé. Veuillez réessayer.",
"password-unmatch": "Le mot de passe et la confirmation du mot de passe ne correspondent pas.",
"username-invalid": "Le nom d'utilisateur est invalide. Veuillez fournir un nom d'au moins trois caractères pour votre profil.",
"user-already-exists": "Nom d'utilisateur déjà pris. Veuillez en essayer un autre.",
"user-not-found": "Utilisateur non trouvé.",
"wrong-credentials": "Informations d'identification incorrectes.",
"reset-token-expired": "Le jeton de réinitialisation de mot de passe a expiré."
},
"snapps": {
"unallowed-not-https": "L'URL d'origine doit être un lien sécurisé https",
"original-url-missing": "Une URL originale doit être fournie pour la redirection",
"original-url-blacklisted": "Cette URL a été mise sur liste noire ou signalée par l'API VirusTotal",
"missing-secret": "Vous devez fournir un secret pour accéder à cette URL",
"wrong-credentials": "Le secret que vous fournissez ne correspond pas à celui pour débloquer l'URL",
"not-found": "Snapp non trouvé",
"max-snapps": "Vous avez atteint la limite de snapps qu'un compte peut créer",
"disabled": "Ce Snapp est désactivé, par le propriétaire ou après avoir atteint un seuil défini. Si vous pensez qu'il s'agit d'une erreur, essayez de contacter l'administrateur"
},
"blacklisted": {
"user": "Il y a eu un problème avec votre processus d'authentification. Veuillez contacter l'administrateur du service.",
"domain": "Ce snapp redirige vers un domaine sur liste noire. Veuillez contacter l'administrateur du service si vous pensez qu'il s'agit d'une erreur."
},
"generic": "Une erreur est survenue. Si cela persiste, contactez l'administrateur.",
"unauthorized": "Vous n'êtes pas autorisé à faire cela",
"label": "Erreur"
},
"homepage": {
"intro": "Si vous recherchez une solution dabréviation dURL auto-hébergée, Snapp pourrait être ce dont vous avez besoin. Il est conçu pour ceux qui apprécient le contrôle sur la gestion de leurs URL et souhaitent explorer diverses technologies.",
"features": {
"label": "Nos Fonctionnalités",
"ui": {
"label": "Interface Conviviale",
"description": "Snapp fournit une interface conviviale pour un raccourcissement de lien fluide. Découvrez comment commencer !"
},
"auth": {
"label": "Authentification Sécurisée",
"description": "Profitez d'une expérience sécurisée pour vos sessions utilisateur."
},
"shortcode": {
"label": "URLs Courtes Personnalisées",
"description": "Créez des codes courts personnalisés pour vos liens afin de les rendre mémorables et faciles à partager."
},
"expiration": {
"label": "Expiration de Vos URLs",
"description": "Créez des codes courts personnalisés pour vos liens afin de les rendre mémorables et faciles à partager."
},
"secrecy": {
"label": "URLs Protégées",
"description": "Ajoutez une couche de protection supplémentaire avec des liens secrets. Choisissez de partager des liens avec un public sélectionné en utilisant des secrets uniques."
},
"analytics": {
"label": "Analytique",
"description": "Renforcez-vous avec des analyses détaillées pour chaque lien que vous créez. Snapp collecte des métriques de manière anonyme, fournissant des informations sur les engagements des liens tout en respectant la vie privée de vos utilisateurs."
},
"umami": {
"label": "Intégration Umami",
"description": "Intégrez votre instance Snapp avec votre instance <a href={url} class=\"link\">Umami Analytics<a> auto-hébergée ou dans le cloud pour des métriques avancées de vos Snapps."
},
"vtapi": {
"label": "Intégration API VirusTotal",
"description": "Sécurisez les liens passant par votre instance Snapp avec une vérification de la réputation de l'<a href={url} class=\"link\">API VirusTotal</a>."
},
"rest-api": {
"label": "REST API",
"description": "Fonctionnalités demandées par la communauté qui permettent de créer et de gérer vos Snapps à distance via l'endpoint API Rest. Lisez la <a href={url} class=\"link\">documentation Swagger</a> complète ici."
}
},
"getting-started": {
"label": "Commencer",
"claim": "Snapp est une plateforme open source auto-hébergée",
"docker": {
"label": "Utiliser le Conteneur Docker",
"helper": "Utilisez ce docker-compose.yml pour exécuter votre Snapp."
}
},
"migration": {
"label": "Migration",
"description": "Les dernières versions de Snapp incluent l'export CSV pour faciliter la migration. Connectez-vous simplement et importez vos URL depuis le tableau de bord, puis continuez là où vous vous étiez arrêté."
},
"stack": {
"label": "La Stack",
"helper": "La technologie impliquée"
}
}
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Autenticación",
"dashboard": "Taboleiro",
"home": "Inicio",
"metrics": "Métricas",
"settings": "Configuración",
"users": "Usuarios"
},
"admin": {
"label": "Panel de Admin",
"helpers": {
"rpd": "Número de solicitudes API por día.",
"rpm": "Número de solicitudes API por minuto.",
"spu": "Número máximo de URLs curtas que un usuario pode crear.",
"smtp": "Este servidor de correo electrónico utilízase para a recuperación de contrasinais, o envío de invitacións de acceso inicial e outras comunicacións xeradas pola plataforma.",
"watchlists": "As listas de vixilancia afectan á inscrición de usuarios e actualizacións de detalles, mentres que bloquear dominios impedirá o acurtamento de URLs.",
"add-whitelist": "Permitir un correo electrónico ou dominio a través da plataforma.",
"add-blacklist": "Bloquear un correo electrónico ou dominio a través da plataforma.",
"vt-api": "Escanea o dominio utilizando a API de VirusTotal. Snapp verifica a reputación do dominio e bloquea calquera sitio cunha puntuación negativa. As puntuacións positivas ou neutras permitirán o dominio. As solicitudes son cacheadas durante 24 horas."
},
"labels": {
"limits": "Límites da API REST",
"smtp": "Servidor SMTP",
"smtp-host": "Servidor",
"smtp-pass": "Contrasinal",
"smtp-port": "Porto",
"smtp-user": "Usuario",
"smtp-from": "De",
"rpd": "Solicitudes por Día",
"rpm": "Solicitudes por Minuto",
"spu": "Snapp por Usuario",
"blacklist": "Entidades Bloqueadas",
"watchlists": "Listas de Vixilancia",
"whitelist": "Entidades Permitidas",
"add-whitelist": "Permitir Entidade",
"add-blacklist": "Bloquear Entidade",
"blacklisted-items": "{count} elementos na lista bloqueada",
"whitelisted-items": "{count} elementos na lista permitida",
"vt-api": "API de VirusTotal",
"domains": "Dominios",
"emails": "Correos Electrónicos",
"usernames": "Nombres de Usuario"
},
"placeholders": {
"vt-api": "Introduce a túa clave de VTAPI aquí...",
"smtp-pass": "Introduce o teu contrasinal SMTP...",
"smtp-host": "Introduce o teu servidor SMTP...",
"smtp-from": "Introduce a túa dirección SMTP From...",
"smtp-user": "Introduce o teu usuario SMTP...",
"smtp-port": "Introduce o teu porto SMTP...",
"filter-watchlist": "Filtrar entidade nesta lista..."
}
},
"settings": {
"label": {
"language": "Idioma",
"theme": "Tema",
"theme-dark": "Modo Escuro",
"theme-light": "Modo Claro",
"enable-limits": "Habilitar Límites",
"enable-signup": "Habilitar Rexistros",
"disable-homepage": "Deshabilitar Páxina de Inicio",
"allow-http": "Permitir HTTP Non Seguro"
},
"saved": "Os cambios foron gardados.",
"helpers": {
"signups": "Permitir rexistros externos desde o front-end.",
"homepage": "Redirixir aos usuarios ao inicio de sesión ou ao panel no seu lugar.",
"limits": "Limitacións de API solicitadas pola comunidade.",
"http": "Permitir URLs con HTTP en lugar de HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Introduce o teu correo electrónico para comezar a recuperación de contrasinal.",
"recover-password": "Isto enviará un correo electrónico de restablecemento de contrasinal a esta conta."
},
"forgot-password": "¿Esqueciches a túa contrasinal?",
"go-to-signup": "¿Non tes unha conta? Rexístrate na <a class=\"link\" href=\"{url}\">páxina de rexistro</a>.",
"go-to-signin": "¿Xa tes unha conta? Vai á <a class=\"link\" href=\"{url}\">páxina de inicio de sesión</a>.",
"sign-in": "Iniciar sesión",
"sign-up": "Rexistrarse",
"recover-password": "Recuperar Contrasinal",
"reset-email-sent": "A recuperación de contrasinal comezou, enviouse un correo electrónico á dirección proporcionada.",
"post-email-message": "Enviamos un correo electrónico á dirección que proporcionaches."
},
"labels": {
"profile": "Perfil de Usuario",
"create": "Invitar Usuario",
"edit": "Editar Usuario",
"list": "Lista de Usuarios Rexistrados"
},
"fields": {
"email": "Correo Electrónico",
"confirm-password": "Confirmar Contrasinal",
"password": "Contrasinal",
"username": "Nome de Usuario",
"role": "Rol",
"max-snapps": "Máx. URLs",
"notes": "Notas"
},
"helpers": {
"invitation": "O usuario recibirá unha invitación por correo electrónico para configurar a súa contrasinal.",
"notes": "As notas son visibles só para os administradores na páxina de resumo do usuario.",
"admin": "Selecciona o nivel de autorización do usuario.",
"max-snapps": "Limita o número de snapps que este usuario pode crear.",
"password": "A contrasinal debe ter ao menos oito caracteres, incluíndo ao menos unha letra maiúscula, unha letra minúscula, un número e un carácter especial."
},
"placeholders": {
"username": "Escribe o teu nome de usuario...",
"email": "Escribe a túa dirección de correo electrónico...",
"password": "Escribe a túa contrasinal...",
"search": "Buscar nome de usuario ou correo electrónico..."
},
"actions": {
"confirm-delete": "Esta acción eliminará ao usuario e o seu contido permanentemente.",
"deleted": "O usuario foi eliminado.",
"edited": "A información do usuario foi gardada.",
"created": "O usuario foi creado.",
"logout": "Pechar sesión"
}
},
"tokens": {
"label": "Token de API REST",
"placeholder": "A túa clave de API aparecerá aquí...",
"helper": "Autoriza as chamadas á API cunha cabeceira de autorización que conteña o Token xerado. As chaves de API son únicas e están relacionadas coa conta de usuario, compartindo roles de base de datos.",
"api-docs": "Consulta a <a href=\"{url}\" class=\"link\">documentación completa da API</a> para detalles sobre o uso e a implementación.",
"generate": "Xerar Token",
"revoke": "Revogar Token",
"copied": "Token copiado ao portapapeis.",
"not-allowed-to-copy": "O portapapeis do navegador require un entorno HTTPS seguro.",
"not-found": "Token non encontrado.",
"fields": {
"key": "Token",
"created": "Creato"
}
},
"globals": {
"close": "Pechar",
"confirm": "Confirmar",
"cancel": "Cancelar",
"sure-ask": "¿Estás seguro?",
"continue": "Continuar",
"total": "Total",
"max-page-reached": "Non hai máis elementos dispoñibles.",
"save": "Gardar",
"active": "Activo",
"disabled": "Desactivado",
"back": "Volver",
"start": "Iniciar",
"end": "Finalizar",
"all": "Todos",
"world": "Mundo",
"copy": "Copia",
"loading": "Cargando"
},
"metrics": {
"filters": "Filtros",
"origin": "Orixe Xeográfica",
"browser": "Navegador",
"device": "Dispositivo",
"os": "Sistema Operativo"
},
"migrations": {
"label": "Migración",
"import-csv": "Importar desde Snapp CSV",
"export-csv": "Exportar CSV para SNAPP",
"upload": "Subir ficheiro",
"csv-file-required": "A importación require un ficheiro CSV válido de Snapp",
"success": "Snapps importados correctamente",
"failed": "Importación fallida",
"requires-time": "Exportaranse todos os snapps. Esta acción pode requirir algo de tempo."
},
"snapps": {
"label": "Lista de URLs",
"placeholders": {
"original-url": "Pega a túa URL orixinal",
"shortcode": "Especifica un código curto personalizado",
"secret": "Especifica un segredo para protexer a túa URL",
"search": "Buscar por URL orixinal ou código curto"
},
"fields": {
"original-url": "URL Orixinal",
"shortcode": "Código Curto",
"has-expiration": "Establecer unha expiración",
"has-secret": "Establecer un Segredo",
"has-limited-usage": "Ten uso limitado",
"secret": "Segredo",
"expiration": "Expiración",
"max-usages": "Usos Máximos",
"used": "Usado",
"created": "Creato",
"hit": "Visitas",
"notes": "Notas",
"status": "Estado"
},
"labels": {
"edit": "Editar Snapp",
"create": "Acurtar unha URL",
"url-info": "Información da URL",
"count": "Total de snapps",
"columns": "Columnas",
"open-link": "Abrir Snapp nunha nova ventá",
"details": "Detalles do Snapp"
},
"helpers": {
"create": "Podes especificar unha URL curta personalizada. Se se deixa en branco, xerarase automaticamente.",
"original-url": "Debe ser un enlace válido <code>https://</code>",
"shortcode": "Xerarase se non se especifica un personalizado",
"secret": "Esta URL está protexida por un segredo",
"not-secret": "Esta URL é pública",
"not-max-usages": "Sen Límites",
"provide-secret": "Por favor, proporciona a contrasinal correcta para continuar",
"text-pasted": "A URL orixinal foi pegada desde o portapapeis",
"has-secret": "Especifica un Segredo para protexer a URL de accesos non desexados",
"expiration": "Especifica unha data de expiración para desactivar automáticamente a túa URL",
"max-usages": "Especifica unha cantidade limitada de usos para este snapp. Despois de alcanzar o límite, o snapp será desactivado",
"previous-expiration": "Este Snapp xa ten unha expiración establecida, expirando en {relativeTime}",
"previous-secret": "Este Snapp xa ten un segredo establecido",
"remove-expiration": "Eliminar expiración",
"remove-secret": "Eliminar segredo",
"disable-text-1": "Pausar este snapp. Os seus datos, o código curto seleccionado e os datos de visualización serán preservados. O usuario recibirá un mensaxe de desambiguación explicando que a redirección está temporalmente non dispoñible.",
"disable-text-2": "Ademais, cando un snapp se use o número establecido de veces, desactívase por defecto. Pode ser reactivado dende aquí.",
"copied-to-clipboard": "URL copiada correctamente ao portapapeis"
},
"actions": {
"created": "A URL foi acurtada con éxito",
"edited": "O Snapp foi actualizado",
"deleted": "O Snapp foi eliminado",
"confirm-delete": "Esta acción eliminará o snapp permanentemente."
},
"time": {
"seconds": "Segundos",
"minutes": "Minutos",
"hours": "Horas",
"days": "Días",
"weeks": "Semanas",
"months": "Meses",
"years": "Anos"
}
},
"errors": {
"api": {
"token-missing": "Requírese un token de autorización."
},
"auth": {
"email-invalid": "Por favor, proporciona unha dirección de correo electrónico válida.",
"email-registered": "Este correo electrónico xa está asociado cunha conta. Intenta rexistrarte ou recuperar a túa contrasinal.",
"disabled-signups": "Os rexistros están desactivados. Se crees que isto é un erro, contacta co administrador do servizo.",
"password-invalid": "A contrasinal proporcionada non é suficientemente segura. Por favor, intenta de novo.",
"password-unmatch": "A contrasinal e a confirmación da contrasinal non coinciden.",
"username-invalid": "O nome de usuario é inválido. Por favor, proporciona un nome de ao menos tres caracteres para o teu perfil.",
"user-already-exists": "O nome de usuario xa está en uso. Por favor, proba cun outro.",
"user-not-found": "Usuario non encontrado.",
"wrong-credentials": "Credenciais incorrectas.",
"reset-token-expired": "O token de recuperación de contrasinal expirou."
},
"snapps": {
"unallowed-not-https": "A URL orixinal debe ser un enlace seguro https",
"original-url-missing": "Debe proporcionarse unha URL orixinal para a redirección",
"original-url-blacklisted": "Esta URL foi bloqueada ou marcada pola API de VirusTotal",
"missing-secret": "Debes proporcionar un segredo para acceder a esta URL",
"wrong-credentials": "O segredo proporcionado non coincide co que desbloquea a URL",
"not-found": "Snapp non encontrado",
"max-snapps": "Alcanzaches o límite de snapps que unha conta pode crear",
"disabled": "Este Snapp está desactivado, xa sexa polo propietario ou despois de alcanzar un umbral establecido. Se crees que isto é un erro, tenta contactar co administrador"
},
"blacklisted": {
"user": "Houbo un problema co teu proceso de autenticación. Por favor, contacta co administrador do servizo.",
"domain": "Este snapp redirixe a un dominio bloqueado. Por favor, contacta co administrador do servizo se crees que isto é un erro."
},
"generic": "Produciuse un erro. Se isto persiste, contacta co administrador.",
"unauthorized": "Non tes permiso para facer isto",
"label": "Erro"
},
"homepage": {
"intro": "Se buscas unha solución de acurtamento de URLs autoxestionada, Snapp pode ser o que necesitas. Está deseñado para aqueles que valoran o control sobre a xestión das súas URLs e queren explorar diversas tecnoloxías.",
"features": {
"label": "As Nosas Funcionalidades",
"ui": {
"label": "Interface Amigable",
"description": "Snapp ofrece unha interface amigable para un acurtamento de ligazóns sen problemas. Descubre como comezar!"
},
"auth": {
"label": "Autenticación Segura",
"description": "Disfruta dunha experiencia segura para as túas sesións de usuario."
},
"shortcode": {
"label": "URLs Curtas Personalizadas",
"description": "Crea códigos curtos personalizados para os teus enlaces para facelos memorables e fáciles de compartir."
},
"expiration": {
"label": "Expiración das Túas URLs",
"description": "Crea códigos curtos personalizados para os teus enlaces para facelos memorables e fáciles de compartir."
},
"secrecy": {
"label": "URLs Protexidas",
"description": "Engade unha capa adicional de protección con enlaces secretos. Elixe compartir enlaces cun público seleccionado utilizando secretos únicos."
},
"analytics": {
"label": "Analítica",
"description": "Poténciate con análises detalladas para cada enlace que creas. Snapp recolle métricas de forma anónima, proporcionando información sobre o compromiso cos enlaces e respectando a privacidade dos teus usuarios."
},
"umami": {
"label": "Integración con Umami",
"description": "Integra a túa instancia de Snapp coa túa instancia de <a href={url} class=\"link\">Umami Analytics<a> auto-hospedada ou na nube para métricas avanzadas dos teus Snapps."
},
"vtapi": {
"label": "Integración API VirusTotal",
"description": "Asegura os enlaces que pasan pola túa instancia de Snapp cunha verificación da reputación da <a href={url} class=\"link\">API VirusTotal</a>."
},
"rest-api": {
"label": "REST API",
"description": "Funcionalidades solicitadas pola comunidade que permiten ao endpoint REST API crear e xestionar os teus Snapps de forma remota. Lee a <a href={url} class=\"link\">documentación Swagger</a> completa aquí."
}
},
"getting-started": {
"label": "Comezar",
"claim": "Snapp é unha plataforma open source auto-hospedada",
"docker": {
"label": "Uso do Contedor Docker",
"helper": "Utiliza este docker-compose.yml para executar o teu Snapp."
}
},
"migration": {
"label": "Migración",
"description": "As últimas versións de Snapp inclúen exportación CSV para facilitar a migración. Simplemente inicia sesión e importa as túas URLs desde o panel de control, e continúa dende onde o deixaches."
},
"stack": {
"label": "A Stack",
"helper": "A tecnoloxía involucrada"
}
}
}

View File

@@ -0,0 +1,337 @@
{
"appname": "Snapp",
"menu": {
"authenticate": "Accesso",
"dashboard": "Dashboard",
"home": "Home",
"metrics": "Metriche",
"settings": "Configura",
"users": "Utenti"
},
"admin": {
"label": "Pannello Admin",
"helpers": {
"rpd": "Numero di richieste API al giorno.",
"rpm": "Numero di richieste API al minuto.",
"spu": "Numero massimo di URL brevi che un utente può creare.",
"smtp": "Questo server di posta viene utilizzato per il recupero della password, l&rsquo;invio di inviti al primo accesso e altre comunicazioni generate dalla piattaforma.",
"watchlists": "Le liste di controllo influenzano la registrazione e l&rsquo;aggiornamento dei dettagli degli utenti, mentre il blocco dei domini impedirà l&rsquo;accorciamento degli URL.",
"add-whitelist": "Consenti un&rsquo;email o un dominio su tutta la piattaforma.",
"add-blacklist": "Blocca un&rsquo;email o un dominio su tutta la piattaforma.",
"vt-api": "Scansiona il dominio utilizzando l&rsquo;API di VirusTotal. Snapp controlla la reputazione del dominio e blocca qualsiasi sito con un punteggio negativo. I punteggi positivi o neutrali consentiranno il dominio. Le richieste sono memorizzate nella cache per 24 ore."
},
"labels": {
"limits": "Limiti API REST",
"smtp": "Server SMTP",
"smtp-host": "Server",
"smtp-pass": "Password",
"smtp-port": "Porta",
"smtp-user": "Utente",
"smtp-from": "Da",
"rpd": "Richieste al Giorno",
"rpm": "Richieste al Minuto",
"spu": "Snapp per Utente",
"blacklist": "Entità Bloccate",
"watchlists": "Liste di Controllo",
"whitelist": "Entità Consentite",
"add-whitelist": "Consenti Entità",
"add-blacklist": "Blocca Entità",
"blacklisted-items": "{count} elementi nella lista bloccati",
"whitelisted-items": "{count} elementi nella lista consentiti",
"vt-api": "API VirusTotal",
"domains": "Domini",
"emails": "Email",
"usernames": "Nomi Utente"
},
"placeholders": {
"vt-api": "Inserisci qui la tua chiave VTAPI...",
"smtp-pass": "Inserisci qui la tua password SMTP...",
"smtp-host": "Inserisci qui il tuo host SMTP...",
"smtp-from": "Inserisci qui il tuo indirizzo &rsquo;Da&rsquo; SMTP...",
"smtp-user": "Inserisci qui il tuo utente SMTP...",
"smtp-port": "Inserisci qui la tua porta SMTP...",
"filter-watchlist": "Filtra entità in questa lista..."
}
},
"settings": {
"label": {
"language": "Lingua",
"theme": "Tema",
"theme-dark": "Modalità Scura",
"theme-light": "Modalità Chiara",
"enable-limits": "Abilita Limiti",
"enable-signup": "Abilita Registrazioni",
"disable-homepage": "Disabilita Homepage",
"allow-http": "HTTP Non Sicuro"
},
"saved": "Le modifiche sono state salvate.",
"helpers": {
"signups": "Consenti registrazioni esterne dal front-end.",
"homepage": "Reindirizza gli utenti al login o al Dashboard.",
"limits": "Limitazioni API richieste dalla comunità.",
"http": "Consenti URL con HTTP invece di HTTPS."
}
},
"users": {
"auth": {
"helpers": {
"forgot-password": "Inserisci la tua email per avviare il recupero della password.",
"recover-password": "Questo invierà un&rsquo;email di reset della password a questo account."
},
"forgot-password": "Hai dimenticato la password?",
"go-to-signup": "Non hai un account? Registrati alla <a class=\"link\" href=\"{url}\">pagina di registrazione</a>.",
"go-to-signin": "Hai già un account? Vai alla <a class=\"link\" href=\"{url}\">pagina di login</a>.",
"sign-in": "Accedi",
"sign-up": "Registrati",
"recover-password": "Recupera Password",
"reset-email-sent": "Recupero password avviato, un&rsquo;email è stata inviata all&rsquo;indirizzo fornito.",
"post-email-message": "Abbiamo inviato un&rsquo;email all&rsquo;indirizzo fornito."
},
"labels": {
"profile": "Profilo Utente",
"create": "Invita Utente",
"edit": "Modifica Utente",
"list": "Elenco degli Utenti Registrati"
},
"fields": {
"email": "Email",
"confirm-password": "Conferma Password",
"password": "Password",
"username": "Nome Utente",
"role": "Ruolo",
"max-snapps": "Max URL",
"notes": "Note"
},
"helpers": {
"invitation": "L&rsquo;utente riceverà un&rsquo;email di invito per impostare la propria password.",
"notes": "Le note sono visibili solo agli amministratori nella pagina di panoramica degli utenti.",
"admin": "Seleziona il livello di autorizzazione dell&rsquo;utente.",
"max-snapps": "Limita il numero di snapp che questo utente può creare.",
"password": "La password deve essere lunga almeno otto caratteri, con almeno una lettera maiuscola, una lettera minuscola, un numero e un carattere speciale."
},
"placeholders": {
"username": "Digita il tuo nome utente...",
"email": "Digita il tuo indirizzo email...",
"password": "Digita la tua password...",
"search": "Cerca nome utente o email..."
},
"actions": {
"confirm-delete": "Questa azione eliminerà l&rsquo;utente e il suo contenuto in modo permanente.",
"deleted": "L&rsquo;utente è stato eliminato.",
"edited": "Le informazioni dell&rsquo;utente sono state salvate.",
"created": "L&rsquo;utente è stato creato.",
"logout": "Esci"
}
},
"tokens": {
"label": "Token API REST",
"placeholder": "La tua chiave API apparirà qui...",
"helper": "Autorizza le chiamate API con un&rsquo;intestazione Authorization contenente il Token generato. Le chiavi API sono uniche e relative all&rsquo;account utente, condividendo i ruoli del database.",
"api-docs": "Consulta la completa <a href=\"{url}\" class=\"link\">documentazione API</a> per i dettagli sull&rsquo;utilizzo e l&rsquo;implementazione.",
"generate": "Genera Token",
"revoke": "Revoca Token",
"copied": "Token copiato negli appunti.",
"not-allowed-to-copy": "La clipboard del browser richiede un ambiente HTTPS sicuro.",
"not-found": "Token non trovato.",
"fields": {
"key": "Token",
"created": "Creato"
}
},
"globals": {
"close": "Chiudi",
"confirm": "Conferma",
"cancel": "Annulla",
"sure-ask": "Sei sicuro?",
"continue": "Continua",
"total": "Totale",
"max-page-reached": "Non ci sono più elementi disponibili.",
"save": "Salva",
"active": "Attivo",
"disabled": "Disabilitato",
"back": "Indietro",
"start": "Inizio",
"end": "Fine",
"all": "Tutti",
"world": "Mondo",
"copy": "Copia",
"loading": "Caricamento"
},
"metrics": {
"filters": "Filtri",
"origin": "Origine Geografica",
"browser": "Browser",
"device": "Dispositivo",
"os": "Sistema Operativo"
},
"migrations": {
"label": "Migrazione",
"import-csv": "Importa da Snapp CSV",
"export-csv": "Esporta CSV per SNAPP",
"upload": "Carica file",
"csv-file-required": "L&rsquo;importazione richiede un file CSV valido di Snapp",
"success": "Snapps importati correttamente",
"failed": "Importazione fallita",
"requires-time": "Tutti gli snapp verranno esportati, questo potrebbe richiedere del tempo."
},
"snapps": {
"label": "Elenco URL",
"placeholders": {
"original-url": "Incolla il tuo URL originale",
"shortcode": "Specifica un codice breve personalizzato",
"secret": "Specifica un segreto per proteggere il tuo URL",
"search": "Cerca per URL originale o codice breve"
},
"fields": {
"original-url": "URL Originale",
"shortcode": "Codice Breve",
"has-expiration": "Imposta una scadenza",
"has-secret": "Imposta un Segreto",
"has-limited-usage": "Ha utilizzi limitati",
"secret": "Segreto",
"expiration": "Scadenza",
"max-usages": "Usi Massimi",
"used": "Utilizzati",
"created": "Creato",
"hit": "Visite",
"notes": "Note",
"status": "Stato"
},
"labels": {
"edit": "Modifica Snapp",
"create": "Accorcia un URL",
"url-info": "Informazioni URL",
"count": "Totale snapps",
"columns": "Colonne",
"open-link": "Apri Snapp in una nuova finestra",
"details": "Dettagli Snapp"
},
"helpers": {
"create": "Puoi specificare un URL breve personalizzato. Se lasciato vuoto, verrà generato automaticamente",
"original-url": "Questo deve essere un link valido <code>https://</code>",
"shortcode": "Questo verrà generato se non viene specificato un codice personalizzato",
"secret": "Questo URL è protetto da un segreto",
"not-secret": "Questo URL è pubblico",
"not-max-usages": "Nessun Limite",
"provide-secret": "Fornisci la password corretta per continuare",
"text-pasted": "L&rsquo;URL originale è stato incollato dagli appunti",
"has-secret": "Specifica un Segreto per proteggere l&rsquo;URL da accessi non autorizzati",
"expiration": "Specifica una data di scadenza per disabilitare automaticamente il tuo URL",
"max-usages": "Specifica un numero limitato di utilizzi per questo snapp. Dopo aver raggiunto il limite, lo snapp sarà disabilitato",
"previous-expiration": "Questo Snapp ha già una scadenza impostata, scade {relativeTime}",
"previous-secret": "Questo Snapp ha già un segreto impostato",
"remove-expiration": "Rimuovi scadenza",
"remove-secret": "Rimuovi segreto",
"disable-text-1": "Metti in pausa questo snapp. I suoi dati, il codice breve selezionato e i dati di visualizzazione verranno conservati. L&rsquo;utente riceverà un messaggio di disambiguazione che spiega che il reindirizzamento è temporaneamente non disponibile.",
"disable-text-2": "Anche quando uno snapp viene utilizzato per il numero di volte impostato, viene disabilitato per impostazione predefinita. Può essere riabilitato da qui.",
"copied-to-clipboard": "URL correttamete copiato negli appunti"
},
"actions": {
"created": "L&rsquo;URL è stato accorciato con successo",
"edited": "Lo Snapp è stato aggiornato",
"deleted": "Lo Snapp è stato eliminato",
"confirm-delete": "Questa azione eliminerà lo snapp permanentemente"
},
"time": {
"seconds": "Secondi",
"minutes": "Minuti",
"hours": "Ore",
"days": "Giorni",
"weeks": "Settimane",
"months": "Mesi",
"years": "Anni"
}
},
"errors": {
"api": {
"token-missing": "Token di autorizzazione richiesto."
},
"auth": {
"email-invalid": "Per favore, fornisci un indirizzo email valido.",
"email-registered": "Questa email è già associata a un account. Prova a registrarti o a recuperare la tua password.",
"disabled-signups": "Le registrazioni sono disabilitate. Se pensi che sia un errore, contatta l&rsquo;amministratore del servizio.",
"password-invalid": "La password fornita non è abbastanza sicura. Per favore, riprova.",
"password-unmatch": "La password e la conferma della password non corrispondono.",
"username-invalid": "Il nome utente non è valido. Fornisci un nome di almeno tre caratteri per il tuo profilo.",
"user-already-exists": "Nome utente già in uso. Prova con un altro.",
"user-not-found": "Utente non trovato.",
"wrong-credentials": "Credenziali errate.",
"reset-token-expired": "Il token di reset per il recupero della password è scaduto."
},
"snapps": {
"unallowed-not-https": "L&rsquo;URL originale deve essere un link HTTPS sicuro.",
"original-url-missing": "Deve essere fornito un URL originale per il reindirizzamento.",
"original-url-blacklisted": "Questo URL è stato inserito nella blacklist o segnalato dall&rsquo;API VirusTotal.",
"missing-secret": "Devi fornire un segreto per accedere a questo URL.",
"wrong-credentials": "Il segreto fornito non corrisponde a quello per sbloccare l&rsquo;URL.",
"not-found": "Snapp non trovato.",
"max-snapps": "Hai raggiunto il limite di snapp che un account può creare.",
"disabled": "Questo Snapp è disabilitato, dall&rsquo;utente o dopo aver raggiunto una soglia impostata. Se pensi che sia un errore, contatta l&rsquo;amministratore."
},
"blacklisted": {
"user": "C&rsquo;è stato un problema con il tuo processo di autenticazione. Per favore, contatta l&rsquo;amministratore del servizio.",
"domain": "Questo snapp reindirizza a un dominio nella blacklist. Per favore, contatta l&rsquo;amministratore del servizio se pensi che sia un errore."
},
"generic": "Si è verificato un errore. Se il problema persiste, contatta l&rsquo;amministratore.",
"unauthorized": "Non sei autorizzato a fare questo.",
"label": "Errore"
},
"homepage": {
"intro": "Se stai cercando una soluzione di accorciamento URL auto-ospitata, Snapp potrebbe essere ciò di cui hai bisogno. È progettato per coloro che apprezzano il controllo sulla gestione delle loro URL e vogliono esplorare varie tecnologie.",
"features": {
"label": "Le Nostre Features",
"ui": {
"label": "Interfaccia Intuitiva",
"description": "Snapp offre un&rsquo;interfaccia intuitiva per un accorciamento dei link senza problemi. Leggi come iniziare!"
},
"auth": {
"label": "Autenticazione Sicura",
"description": "Goditi un&rsquo;esperienza sicura per le tue sessioni utente."
},
"shortcode": {
"label": "URL Brevi Personalizzati",
"description": "Crea codici brevi personalizzati per i tuoi link per renderli memorabili e facili da condividere."
},
"expiration": {
"label": "Scadenza degli URL",
"description": "Crea codici brevi personalizzati per i tuoi link per renderli memorabili e facili da condividere."
},
"secrecy": {
"label": "URL Protetti",
"description": "Aggiungi uno strato extra di protezione con link segreti. Scegli di condividere i link con un pubblico selezionato utilizzando segreti unici."
},
"analytics": {
"label": "Metriche",
"description": "Rendi te stesso più potente con analisi dettagliate per ogni link che crei. Snapp raccoglie metriche in modo anonimo, fornendo informazioni sugli impegni dei link e rispettando la privacy dei tuoi utenti."
},
"umami": {
"label": "Integrazione Umami",
"description": "Integra la tua istanza di Snapp con la tua istanza <a href={url} class=\"link\">Umami Analytics<a> auto-ospitata o in cloud per metriche avanzate dei tuoi Snapp."
},
"vtapi": {
"label": "Integrazione API VirusTotal",
"description": "Metti in sicurezza i link che passano attraverso la tua istanza di Snapp con un controllo sulla reputazione dell&rsquo;<a href={url} class=\"link\">API VirusTotal</a>."
},
"rest-api": {
"label": "REST API",
"description": "Funzionalità richieste dalla comunità che abilitano l&rsquo;endpoint API Rest per creare e gestire i tuoi Snapp da remoto. Leggi la completa <a href={url} class=\"link\">documentazione Swagger</a> qui."
}
},
"getting-started": {
"label": "Come Iniziare",
"claim": "Snapp è una piattaforma open source auto-ospitata",
"docker": {
"label": "Utilizzare il Container Docker",
"helper": "Usa questo docker-compose.yml per eseguire il tuo Snapp."
}
},
"migration": {
"label": "Migrazione",
"description": "Le ultime versioni di Snapp includono l&rsquo;Export CSV per facilitare la migrazione. Accedi semplicemente e importa i tuoi URL dal pannello di controllo, e continua da dove avevi lasciato."
},
"stack": {
"label": "Lo Stack",
"helper": "La tecnologia coinvolta"
}
}
}

View File

@@ -1 +0,0 @@
type Translation = Record<string,string>

View File

@@ -1,25 +0,0 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.71875 3.92972C5.62789 3.36733 8.45568 2.52411 9.64828 2.16812C10.0173 2.05797 10.4075 2.05711 10.7771 2.16533C11.926 2.50173 14.5984 3.28783 16.6648 3.92349C17.0828 4.05209 17.3679 4.44593 17.3575 4.88319C17.1188 14.9867 12.0196 17.4105 10.5842 17.8936C10.3388 17.9762 10.0837 17.9764 9.83802 17.8945C8.39567 17.4137 3.25421 14.9951 3.00907 4.89714C2.9983 4.45317 3.29276 4.05521 3.71875 3.92972Z" fill="#D9D9D9"/>
<mask id="mask0_156_252" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="3" y="2" width="15" height="16">
<path d="M3.71875 3.92972C5.62789 3.36733 8.45568 2.52411 9.64828 2.16812C10.0173 2.05797 10.4075 2.05711 10.7771 2.16533C11.926 2.50173 14.5984 3.28783 16.6648 3.92349C17.0828 4.05209 17.3679 4.44593 17.3575 4.88319C17.1188 14.9867 12.0196 17.4105 10.5842 17.8936C10.3388 17.9762 10.0837 17.9764 9.83802 17.8945C8.39567 17.4137 3.25421 14.9951 3.00907 4.89714C2.9983 4.45317 3.29276 4.05521 3.71875 3.92972Z" fill="#D9D9D9"/>
</mask>
<g mask="url(#mask0_156_252)">
<path d="M10.2112 9.77465L10.0986 1.71831L17.8169 3.80282L10.2112 9.77465Z" fill="url(#paint0_linear_156_252)"/>
<path d="M10.2113 9.77465V2L2.83098 3.97183L1.81689 8.76056L5.02816 13.7746L10.2113 9.77465Z" fill="url(#paint1_linear_156_252)"/>
<path d="M17.5915 3.97183L4.91547 13.7746V18.2254H17.4225L17.5915 3.97183Z" fill="url(#paint2_linear_156_252)"/>
</g>
<defs>
<linearGradient id="paint0_linear_156_252" x1="10.2112" y1="2" x2="15.6197" y2="5.5493" gradientUnits="userSpaceOnUse">
<stop stop-color="#19AAE8"/>
<stop offset="1" stop-color="#1EA5F1"/>
</linearGradient>
<linearGradient id="paint1_linear_156_252" x1="3.05633" y1="4.14085" x2="10.2113" y2="9.66197" gradientUnits="userSpaceOnUse">
<stop stop-color="#2BE2B8"/>
<stop offset="1" stop-color="#19B9E3"/>
</linearGradient>
<linearGradient id="paint2_linear_156_252" x1="17.3098" y1="4.02817" x2="7.95772" y2="16.9859" gradientUnits="userSpaceOnUse">
<stop stop-color="#925CDF"/>
<stop offset="1" stop-color="#CC42E5"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="currentColor"><path d="M23.763 6.886c-.065-.053-.673-.512-1.954-.512-.32 0-.659.03-1.01.087-.248-1.703-1.651-2.533-1.716-2.57l-.345-.2-.227.328a4.596 4.596 0 0 0-.611 1.433c-.23.972-.09 1.884.403 2.666-.596.331-1.546.418-1.744.42H.752a.753.753 0 0 0-.75.749c-.007 1.456.233 2.864.692 4.07.545 1.43 1.355 2.483 2.409 3.13 1.181.725 3.104 1.14 5.276 1.14 1.016 0 2.03-.092 2.93-.266 1.417-.273 2.705-.742 3.826-1.391a10.497 10.497 0 0 0 2.61-2.14c1.252-1.42 1.998-3.005 2.553-4.408.075.003.148.005.221.005 1.371 0 2.215-.55 2.68-1.01.505-.5.685-.998.704-1.053L24 7.076l-.237-.19Z"></path><path d="M2.216 8.075h2.119a.186.186 0 0 0 .185-.186V6a.186.186 0 0 0-.185-.186H2.216A.186.186 0 0 0 2.031 6v1.89c0 .103.083.186.185.186Zm2.92 0h2.118a.185.185 0 0 0 .185-.186V6a.185.185 0 0 0-.185-.186H5.136A.185.185 0 0 0 4.95 6v1.89c0 .103.083.186.186.186Zm2.964 0h2.118a.186.186 0 0 0 .185-.186V6a.186.186 0 0 0-.185-.186H8.1A.185.185 0 0 0 7.914 6v1.89c0 .103.083.186.186.186Zm2.928 0h2.119a.185.185 0 0 0 .185-.186V6a.185.185 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm-5.892-2.72h2.118a.185.185 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186H5.136a.186.186 0 0 0-.186.186v1.89c0 .103.083.186.186.186Zm2.964 0h2.118a.186.186 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186H8.1a.186.186 0 0 0-.186.186v1.89c0 .103.083.186.186.186Zm2.928 0h2.119a.185.185 0 0 0 .185-.186V3.28a.186.186 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm0-2.72h2.119a.186.186 0 0 0 .185-.186V.56a.185.185 0 0 0-.185-.186h-2.119a.186.186 0 0 0-.185.186v1.89c0 .103.083.186.185.186Zm2.955 5.44h2.118a.185.185 0 0 0 .186-.186V6a.185.185 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.186v1.89c0 .103.083.186.185.186Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,3 +0,0 @@
<svg aria-hidden="true" viewBox="0 0 16 16" version="1.1" width="32" data-view-component="true" class="octicon octicon-mark-github v-align-middle text-primary-900-50-token w-full h-full">
<path fill="currentColor" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z"></path>
</svg>

Before

Width:  |  Height:  |  Size: 808 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="lucide-logo">
<path d="M14 12C14 9.79086 12.2091 8 10 8C7.79086 8 6 9.79086 6 12C6 16.4183 9.58172 20 14 20C18.4183 20 22 16.4183 22 12C22 8.446 20.455 5.25285 18 3.05557" stroke="currentColor"/>
<path d="M10 12C10 14.2091 11.7909 16 14 16C16.2091 16 18 14.2091 18 12C18 7.58172 14.4183 4 10 4C5.58172 4 2 7.58172 2 12C2 15.5841 3.57127 18.8012 6.06253 21" stroke="#F56565"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

View File

@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg viewBox="0 -18 256 256" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet"><path d="M245.97 168.943c-13.662 7.121-84.434 36.22-99.501 44.075-15.067 7.856-23.437 7.78-35.34 2.09-11.902-5.69-87.216-36.112-100.783-42.597C3.566 169.271 0 166.535 0 163.951v-25.876s98.05-21.345 113.879-27.024c15.828-5.679 21.32-5.884 34.79-.95 13.472 4.936 94.018 19.468 107.331 24.344l-.006 25.51c.002 2.558-3.07 5.364-10.024 8.988" fill="#912626"/><path d="M245.965 143.22c-13.661 7.118-84.431 36.218-99.498 44.072-15.066 7.857-23.436 7.78-35.338 2.09-11.903-5.686-87.214-36.113-100.78-42.594-13.566-6.485-13.85-10.948-.524-16.166 13.326-5.22 88.224-34.605 104.055-40.284 15.828-5.677 21.319-5.884 34.789-.948 13.471 4.934 83.819 32.935 97.13 37.81 13.316 4.881 13.827 8.9.166 16.02" fill="#C6302B"/><path d="M245.97 127.074c-13.662 7.122-84.434 36.22-99.501 44.078-15.067 7.853-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.112-100.783-42.597C3.566 127.402 0 124.67 0 122.085V96.206s98.05-21.344 113.879-27.023c15.828-5.679 21.32-5.885 34.79-.95C162.142 73.168 242.688 87.697 256 92.574l-.006 25.513c.002 2.557-3.07 5.363-10.024 8.987" fill="#912626"/><path d="M245.965 101.351c-13.661 7.12-84.431 36.218-99.498 44.075-15.066 7.854-23.436 7.777-35.338 2.087-11.903-5.686-87.214-36.112-100.78-42.594-13.566-6.483-13.85-10.947-.524-16.167C23.151 83.535 98.05 54.148 113.88 48.47c15.828-5.678 21.319-5.884 34.789-.949 13.471 4.934 83.819 32.933 97.13 37.81 13.316 4.88 13.827 8.9.166 16.02" fill="#C6302B"/><path d="M245.97 83.653c-13.662 7.12-84.434 36.22-99.501 44.078-15.067 7.854-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.113-100.783-42.595C3.566 83.98 0 81.247 0 78.665v-25.88s98.05-21.343 113.879-27.021c15.828-5.68 21.32-5.884 34.79-.95C162.142 29.749 242.688 44.278 256 49.155l-.006 25.512c.002 2.555-3.07 5.361-10.024 8.986" fill="#912626"/><path d="M245.965 57.93c-13.661 7.12-84.431 36.22-99.498 44.074-15.066 7.854-23.436 7.777-35.338 2.09C99.227 98.404 23.915 67.98 10.35 61.497-3.217 55.015-3.5 50.55 9.825 45.331 23.151 40.113 98.05 10.73 113.88 5.05c15.828-5.679 21.319-5.883 34.789-.948 13.471 4.935 83.819 32.934 97.13 37.811 13.316 4.876 13.827 8.897.166 16.017" fill="#C6302B"/><path d="M159.283 32.757l-22.01 2.285-4.927 11.856-7.958-13.23-25.415-2.284 18.964-6.839-5.69-10.498 17.755 6.944 16.738-5.48-4.524 10.855 17.067 6.391M131.032 90.275L89.955 73.238l58.86-9.035-17.783 26.072M74.082 39.347c17.375 0 31.46 5.46 31.46 12.194 0 6.736-14.085 12.195-31.46 12.195s-31.46-5.46-31.46-12.195c0-6.734 14.085-12.194 31.46-12.194" fill="#FFF"/><path d="M185.295 35.998l34.836 13.766-34.806 13.753-.03-27.52" fill="#621B1C"/><path d="M146.755 51.243l38.54-15.245.03 27.519-3.779 1.478-34.791-13.752" fill="#9A2928"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1 +0,0 @@
<svg class="fill-token h-12 w-10" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 104 120"><path fill-rule="evenodd" d="M51.486 30.632c15.041 0 27.881 5.208 37.07 13.764 1.226-.379 2.265-.767 3.118-1.165 2.606-1.215 6.028-3.706 10.265-7.474-.688 6.563-1.427 11.381-2.216 14.454-.423 1.646-1.077 3.639-1.964 5.978a47.987 47.987 0 0 1 5.062 14.02c3.625 18.17-2.583 26.205-16.988 30.69.108.54.193 1.12.253 1.741.515 5.315-2.173 13.899-5.4 13.899-2.122 0-3.502-2.956-4.982-7.27-.463 4.996-2.74 10.731-5.379 10.731-2.222 0-3.63-3.24-5.192-7.889l-.22-.657c-.366-1.11-.744-2.292-1.145-3.522-.1.474-.205.96-.314 1.452l-.134.593C62.208 114.832 60.594 120 57.987 120c-3.027 0-1.367-9.529-6.47-13.567-25.873 1.41-47.638-8.154-47.638-35.75 0-5.078 1.202-10.039 3.405-14.643a55.737 55.737 0 0 1-1.1-2.28C4.467 49.97 2.405 44.232 0 36.55c6.153 4.59 10.872 7.325 14.157 8.204.329.088.671.165 1.027.23 8.617-8.633 21.5-14.352 36.302-14.352ZM45.45 67.86c-8.742 0-15.829 6.882-15.829 15.372s7.087 15.372 15.829 15.372c8.741 0 15.828-6.882 15.828-15.372 0-8.386-6.915-15.204-15.51-15.37Zm22.466 19.2c-2.645 0-4.04 5.648-4.04 8.182 0 1.979.664 3.071 1.746 3.27 2.626.244 1.825-3.583 3.132-3.583 1.394 0 3.026 3.915 4.392 3.384 1.622-.966 1.002-3.07.442-4.734-1.973-4.18-3.027-6.519-5.672-6.519Zm-22.1-16.981c7.549 0 13.668 5.862 13.668 13.093s-6.12 13.093-13.668 13.093c-.464 0-.923-.022-1.376-.066 6.903-.66 12.292-6.241 12.292-13.027s-5.39-12.367-12.291-13.028c.452-.043.911-.065 1.375-.065Zm39.31-3.12c-6.024 0-10.907 6.044-10.907 13.5S79.102 93.96 85.126 93.96s10.907-6.045 10.907-13.5c0-7.457-4.883-13.502-10.907-13.502Zm.352 2.228c5.03 0 9.107 5.039 9.107 11.255 0 6.215-4.077 11.254-9.107 11.254a7.47 7.47 0 0 1-1.368-.126c4.38-.816 7.739-5.487 7.739-11.128S88.49 70.129 84.11 69.313a7.46 7.46 0 0 1 1.368-.126Zm2.094-43.29c.35.227.448.694.222 1.044l-1.792 2.76a.754.754 0 0 1-.945.276l-.098-.054a.754.754 0 0 1-.222-1.043l1.792-2.76a.754.754 0 0 1 1.043-.223Zm-71.6-.728.07.087 1.652 2.36a.754.754 0 0 1-1.235.865l-1.652-2.36a.754.754 0 0 1 1.165-.952ZM53.954 0l1.709 14.476 6.371-7.161.165 17.826-1.263-.28c-3.684-.818-7.026-1.225-10.024-1.225-2.976 0-5.765.402-8.372 1.204l-1.404.433 1.15-22.296 5.828 11.018L53.954 0Zm-1.123 8.03-4.498 10.778-4.378-8.277-.617 11.951.131-.032c2.3-.567 4.717-.857 7.253-.87l.19-.001c2.748 0 5.747.322 9 .965l.207.041-.092-9.92-5.87 6.597L52.831 8.03Zm23.312 11.54a.755.755 0 0 1 .518.933l-.227.791a.754.754 0 0 1-.832.539l-.1-.022a.754.754 0 0 1-.518-.933l.227-.791a.754.754 0 0 1 .932-.517Zm-45.549-.85.424 1.306a.754.754 0 0 1-1.396.561l-.038-.095-.424-1.305a.754.754 0 0 1 1.434-.466Z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

49
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Lucia } from 'lucia';
import { dev } from '$app/environment';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { prisma } from '$lib/server/prisma';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
// set to `true` when using HTTPS
secure: !dev
}
},
getUserAttributes: (attributes) => {
return {
// attributes has the type of DatabaseUserAttributes
username: attributes.username,
email: attributes.email,
role: attributes.role,
createdAt: attributes.createdAt,
updatedAt: attributes.updatedAt,
notes: attributes.notes
};
}
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
email: string;
role: 'user' | 'admin' | 'root';
updatedAt: Date;
createdAt: Date;
notes: string;
}
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
}
}

View File

@@ -0,0 +1,19 @@
import { type RequestEvent } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
import { env } from '$env/dynamic/private';
export const authenticate_api = async (event: RequestEvent) => {
try {
const token = event.request.headers.get('authorization')?.split('Bearer ')[1];
if (!token) return null;
const decoded = jwt.verify(token, env.TOKEN_SECRET);
return decoded as {
key: string;
userId: string;
created: Date;
user: { role: 'root' | 'admin' | 'user' };
iat: number;
};
} catch (error) {
return null;
}
};

View File

@@ -0,0 +1,113 @@
import { ENABLED_SIGNUP, INITIALIZED_DB, USER_EXISTS } from '$lib/utils/constants';
import { env } from '$env/dynamic/private';
import { parse_db_setting } from '$lib/server/db/helpers/parse_db_setting';
import { create_user } from './users/create';
import { set_setting } from './settings/set';
import { get_setting } from './settings/get';
import { authenticate } from './users/authenticate';
import { update_user } from './users/update';
import { get_token } from './tokens/get';
import { create_token } from './tokens/create';
import { delete_token } from './tokens/delete';
import { is_admin } from './users/is_admin';
import { is_admin_token } from './tokens/is_admin';
import { delete_settings } from './settings/delete';
import { get_expirable_setting } from './settings/get_expirable';
import { set_watchlist } from './watchlists/set';
import { check_watchlist } from './watchlists/check';
import { get_list } from './watchlists/get_list';
import { count_watchlisted } from './watchlists/count';
import { delete_watchlist } from './watchlists/delete';
import { get_users } from './users/get';
import { get_one } from './users/get_one';
import { createPasswordResetToken } from './users/password_token';
import { delete_user } from './users/delete';
import { create_snapp } from './snapps/create';
import { get_snapp } from './snapps/get';
import { validate } from './snapps/validate_url';
import { edit_snapp } from './snapps/edit';
import { get_one_snapp } from './snapps/get_one';
import { delete_snapp } from './snapps/delete';
import { get_one_by_id } from './users/get_one_by_id';
import { get_one_snapp_by_id } from './snapps/get_one_by_id';
export class Database {
constructor() {
this.init();
}
// SETTINGS
public settings = {
set: set_setting,
get: get_setting,
expirable: get_expirable_setting,
parse: parse_db_setting,
delete: delete_settings
};
// USERS
public users = {
create: create_user,
authenticate,
update: update_user,
get: get_users,
one: get_one,
id: get_one_by_id,
is_admin,
reset_token: createPasswordResetToken,
delete: delete_user
};
// SNAPPS
public snapps = {
create: create_snapp,
edit: edit_snapp,
delete: delete_snapp,
get: get_snapp,
id: get_one_snapp_by_id,
one: get_one_snapp,
validate: validate
};
public tokens = {
get: get_token,
create: create_token,
delete: delete_token,
is_admin: is_admin_token
};
public watchlist = {
set: set_watchlist,
list: get_list,
check: check_watchlist,
count: count_watchlisted,
delete: delete_watchlist
};
private init = async () => {
const is_initialized = this.settings.parse(await this.settings.get(INITIALIZED_DB), true);
if (is_initialized) return;
const admin_username = env.ADMIN_USERNAME || 'admin';
const admin_password = env.ADMIN_PASSWORD || 'password';
const admin_email = env.ADMIN_EMAIL || 'admin@example.com';
const [user, error] = await this.users.create(
admin_username,
admin_email,
admin_password,
undefined,
'root'
);
await this.settings.set(ENABLED_SIGNUP, 'false');
if (error && error === USER_EXISTS) return console.log(USER_EXISTS);
else await this.settings.set(INITIALIZED_DB, 'true');
};
}
const database = globalThis._db || new Database();
if (!globalThis._db) globalThis._db = database;
export { database };

View File

@@ -0,0 +1,6 @@
import type { Setting } from '@prisma/client';
const parse_db_setting = (res: null | Setting, value: number | string | boolean) =>
(res && res.value.toLowerCase() === String(value).toLowerCase()) || false;
export { parse_db_setting };

View File

@@ -0,0 +1,5 @@
import { prisma } from '$lib/server/prisma';
export const delete_settings = async (field: string, userId?: string) => {
await prisma.setting.deleteMany({ where: { id: field, userId } });
};

View File

@@ -0,0 +1,8 @@
import { prisma } from '$lib/server/prisma';
const get_setting = async (field: string, userId?: string) => {
const id = `${field}${(userId && ':' + userId) || ''}`;
return await prisma.setting.findFirst({ where: { id } });
};
export { get_setting };

View File

@@ -0,0 +1,10 @@
import { prisma } from '$lib/server/prisma';
const get_expirable_setting = async (field: string, expiration: Date, userId?: string) => {
expiration.setHours(0, 0, 0, 0);
await prisma.setting.deleteMany({ where: { created: { lte: expiration } } });
const id = `${field}${(userId && ':' + userId) || ''}`;
return await prisma.setting.findFirst({ where: { id } });
};
export { get_expirable_setting };

View File

@@ -0,0 +1,12 @@
import { prisma } from '$lib/server/prisma';
const set_setting = async (field: string, value: string, userId?: string) => {
const id = `${field}${(userId && ':' + userId) || ''}`;
return prisma.setting.upsert({
create: { field, value, userId, id },
update: { value },
where: { id, field, userId }
});
};
export { set_setting };

View File

@@ -0,0 +1,69 @@
import { prisma } from '$lib/server/prisma';
import {
ALLOW_UNSECURE_HTTP,
ENABLE_LIMITS,
MAX_SNAPPS_PER_USER,
SNAPP_ORIGIN_URL_BLACKLISTED,
SNAPP_ORIGIN_URL_REQUESTED
} from '$lib/utils/constants';
import { hash } from '@node-rs/argon2';
import type { Snapp } from '@prisma/client';
import { generateId } from 'lucia';
import { database } from '../database';
export const create_snapp = async (snapp: Partial<Snapp>, userId: string, fetch: SvelteFetch) => {
const is_admin = await database.users.is_admin(userId);
if (!is_admin) {
const api_limited = database.settings.parse(await database.settings.get(ENABLE_LIMITS), true);
const getUserLimits = await database.settings
.get(MAX_SNAPPS_PER_USER)
.then((res) => (res && parseInt(res?.value)) || 0);
const getUserSpecifiLimits = await database.settings
.get(MAX_SNAPPS_PER_USER, userId)
.then((res) => (res && parseInt(res?.value)) || 0);
const max_snapps = Math.max(getUserLimits, getUserSpecifiLimits);
const count = await prisma.snapp.count({ where: { userId } });
if (api_limited && count >= max_snapps) return [null, MAX_SNAPPS_PER_USER] as [null, string];
}
let { original_url, shortcode, notes, secret, expiration, max_usages } = snapp;
if (!original_url || typeof original_url !== 'string' || original_url.trim() === '')
return [null, SNAPP_ORIGIN_URL_REQUESTED] as [null, string];
const allow_http = database.settings.parse(
await database.settings.get(ALLOW_UNSECURE_HTTP),
true
);
if (!allow_http && !original_url.startsWith('https://'))
return [null, ALLOW_UNSECURE_HTTP] as [null, string];
const is_clean_and_whitelisted = await database.snapps.validate(original_url, fetch);
if (!is_clean_and_whitelisted) return [null, SNAPP_ORIGIN_URL_BLACKLISTED] as [null, string];
if (!shortcode) shortcode = generateId(5);
const exists = await prisma.snapp.count({ where: { shortcode: { startsWith: shortcode } } });
const password_hash = secret
? await hash(secret, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
: null;
const new_snapp = await prisma.snapp.create({
data: {
original_url,
userId,
shortcode: exists ? `${shortcode}-${exists}` : shortcode,
notes,
secret: password_hash,
expiration,
max_usages
}
});
return [new_snapp, null] as [Snapp, null];
};

View File

@@ -0,0 +1,18 @@
import { prisma } from '$lib/server/prisma';
import { SNAPP_NOT_FOUND } from '$lib/utils/constants';
import { database } from '../database';
export const delete_snapp = async (userId: string, ...ids: string[]) => {
const is_admin = await database.users.is_admin(userId);
const { count } = await prisma.snapp.deleteMany({
where: {
id: { in: ids },
userId: is_admin ? undefined : userId
}
});
if (count === 0) return [0, SNAPP_NOT_FOUND] as [number, string];
return [count, null] as [number, null];
};

View File

@@ -0,0 +1,76 @@
import { prisma } from '$lib/server/prisma';
import {
ALLOW_UNSECURE_HTTP,
ENABLE_LIMITS,
MAX_SNAPPS_PER_USER,
SNAPP_ORIGIN_URL_BLACKLISTED,
SNAPP_ORIGIN_URL_REQUESTED,
UNAUTHORIZED
} from '$lib/utils/constants';
import { hash } from '@node-rs/argon2';
import type { Snapp } from '@prisma/client';
import { generateId } from 'lucia';
import { database } from '../database';
export const edit_snapp = async (snapp: Snapp, userId: string, fetch: SvelteFetch) => {
const is_admin = await database.users.is_admin(userId);
let {
id,
original_url,
userId: snappUserId,
shortcode,
notes,
secret,
expiration,
max_usages,
disabled
} = snapp;
if (!is_admin && snappUserId !== userId) return [null, UNAUTHORIZED] as [null, string];
if (!original_url || typeof original_url !== 'string' || original_url.trim() === '')
return [null, SNAPP_ORIGIN_URL_REQUESTED] as [null, string];
const allow_http = database.settings.parse(
await database.settings.get(ALLOW_UNSECURE_HTTP),
true
);
if (!allow_http && !original_url.startsWith('https://'))
return [null, ALLOW_UNSECURE_HTTP] as [null, string];
const is_clean_and_whitelisted = await database.snapps.validate(original_url, fetch);
if (!is_clean_and_whitelisted) return [null, SNAPP_ORIGIN_URL_BLACKLISTED] as [null, string];
if (!shortcode) shortcode = generateId(5);
const exists = await prisma.snapp.count({
where: { shortcode: { startsWith: shortcode }, id: { not: id } }
});
const password_hash =
secret !== null && secret !== 'this-snapp-has-secret'
? await hash(secret, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
: secret === null
? null
: undefined;
const edit_snapp = await prisma.snapp.update({
where: { id },
data: {
original_url,
userId: snappUserId,
shortcode: exists ? `${shortcode}-${exists}` : shortcode,
notes,
secret: password_hash,
expiration,
max_usages: max_usages || -1,
disabled
}
});
return [edit_snapp, null] as [Snapp, null];
};

View File

@@ -0,0 +1,54 @@
import { prisma } from '$lib/server/prisma';
import type { Prisma } from '@prisma/client';
export const get_snapp = async (
userId?: string,
query?: string,
limit: number = 10,
offset: number = 0,
orderBy:
| Prisma.SnappOrderByWithRelationInput
| Prisma.SnappOrderByWithRelationInput[]
| undefined = undefined
) => {
await prisma.snapp.deleteMany({ where: { expiration: { lte: new Date() } } });
const snapps = await prisma.snapp
.findMany({
where: {
AND: [
{ userId },
{
OR: query
? [
{
shortcode: {
contains: query
}
},
{
original_url: {
contains: query
}
}
]
: undefined
}
]
},
take: limit,
skip: offset,
orderBy: orderBy
})
.then((res) =>
res.map((item) => {
if (item.secret !== null) {
item.secret = 'this-snapp-has-secret';
}
return item;
})
);
const count = await prisma.snapp.count();
return [snapps, count] as [typeof snapps, number];
};

View File

@@ -0,0 +1,9 @@
import { prisma } from '$lib/server/prisma';
import { SNAPP_NOT_FOUND } from '$lib/utils/constants';
export const get_one_snapp = async (shortcode: string) => {
const snapp = await prisma.snapp.findFirst({ where: { shortcode } });
if (!snapp) return [null, SNAPP_NOT_FOUND] as [null, string];
return [snapp, null] as [typeof snapp, null];
};

View File

@@ -0,0 +1,9 @@
import { prisma } from '$lib/server/prisma';
import { SNAPP_NOT_FOUND } from '$lib/utils/constants';
export const get_one_snapp_by_id = async (id: string) => {
const snapp = await prisma.snapp.findFirst({ where: { id } });
if (!snapp) return [null, SNAPP_NOT_FOUND] as [null, string];
return [snapp, null] as [typeof snapp, null];
};

View File

@@ -0,0 +1,69 @@
import { extractDomain } from '$lib/server/extract-domain';
import { prisma } from '$lib/server/prisma';
import { SNAPP_NOT_FOUND, VIRUSTOTAL_API_KEY } from '$lib/utils/constants';
import { database } from '../database';
export const validate = async (url: string, fetch: SvelteFetch) => {
const domain = extractDomain(url);
if (!domain) return SNAPP_NOT_FOUND;
const thirty_days_ago = new Date();
thirty_days_ago.setMonth(thirty_days_ago.getMonth() - 1);
const VT_APIKEY = await database.settings
.get(VIRUSTOTAL_API_KEY)
.then((res) => res?.value || null);
if (VT_APIKEY) {
await prisma.vtapicache.deleteMany({
where: {
domain,
created: { lte: thirty_days_ago }
}
});
const cached = await prisma.vtapicache.findFirst({ where: { domain } });
const response = cached ? JSON.parse(cached.result) : await get_fresh(domain, fetch, VT_APIKEY);
if (!cached)
await prisma.vtapicache.upsert({
where: { domain },
update: {},
create: {
domain,
result: JSON.stringify(response)
}
});
const is_clean = response.malicious === 0 || response.malicious < response.harmless;
const blacklisted = await database.watchlist.check(domain, null);
return [is_clean, blacklisted].every((i) => i === true);
} else return true;
};
async function get_fresh(domain: string, fetch: SvelteFetch, VT_APIKEY: string) {
const encodedParams = new URLSearchParams();
encodedParams.set('url', domain);
const _url = 'https://www.virustotal.com/api/v3/urls';
const _options = {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/x-www-form-urlencoded',
'x-apikey': VT_APIKEY
},
body: encodedParams
};
const res = await (await fetch(_url, _options)).json();
const analysis = await (
await fetch(res.data.links.self, {
headers: {
'x-apikey': VT_APIKEY
}
})
).json();
return analysis.data.attributes.stats;
}

View File

@@ -0,0 +1,10 @@
import { prisma } from '$lib/server/prisma';
export const check_token = async (secret: string) => {
const token = await prisma.token.findFirst({
where: { key: secret },
include: { user: { select: { role: true } } }
});
return token;
};

View File

@@ -0,0 +1,14 @@
import { prisma } from '$lib/server/prisma';
import { env } from '$env/dynamic/private';
import { generateId } from 'lucia';
import jwt from 'jsonwebtoken';
export const create_token = async (userId: string) => {
await prisma.token.deleteMany({ where: { userId } });
const { key } = await prisma.token.create({ data: { key: generateId(32), userId } });
const token = await prisma.token.findFirst({
where: { key },
include: { user: { select: { role: true, id: true } } }
});
if (token) return jwt.sign(token, env.TOKEN_SECRET);
};

View File

@@ -0,0 +1,5 @@
import { prisma } from '$lib/server/prisma';
export const delete_token = async (userId: string) => {
await prisma.token.deleteMany({ where: { userId } });
};

View File

@@ -0,0 +1,18 @@
import { prisma } from '$lib/server/prisma';
import jwt from 'jsonwebtoken';
import { env } from '$env/dynamic/private';
export const get_token = async (userId: string) => {
const token = await prisma.token.findFirst({
where: { userId },
include: { user: { select: { role: true } } }
});
if (token)
return {
key: token.key,
userId: token.userId,
created: token.created,
jwt: jwt.sign(token, env.TOKEN_SECRET)
};
else return null;
};

View File

@@ -0,0 +1,12 @@
import { prisma } from '$lib/server/prisma';
export const is_admin_token = async (token: string) => {
const role =
(
await prisma.token.findFirst({
where: { key: token },
include: { user: { select: { role: true } } }
})
)?.user?.role || 'user';
return ['admin', 'root'].includes(role);
};

View File

@@ -0,0 +1,33 @@
import { PASSWORD_IS_INVALID, USER_DOES_NOT_EXISTS } from '$lib/utils/constants';
import { prisma } from '$lib/server/prisma';
import { verify } from '@node-rs/argon2';
import { lucia } from '$lib/server/auth';
import type { Cookies } from '@sveltejs/kit';
import type { User } from 'lucia';
type ERROR = typeof USER_DOES_NOT_EXISTS | typeof PASSWORD_IS_INVALID;
const authenticate = async (cookies: Cookies, username: string, password: string) => {
const exists = await prisma.user.findFirst({ where: { username } });
if (exists === null) return [null, USER_DOES_NOT_EXISTS] as [null, ERROR];
const validPassword = await verify(exists.password_hash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
if (!validPassword) return [null, PASSWORD_IS_INVALID] as [null, ERROR];
await prisma.user.update({ where: { id: exists.id }, data: {} }); // updatedAt // last login
const session = await lucia.createSession(exists.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
return [exists, null] as [User, null];
};
export { authenticate };

View File

@@ -0,0 +1,44 @@
import { EMAIL_EXISTS, USER_EXISTS } from '$lib/utils/constants';
import { prisma } from '$lib/server/prisma';
import { generateId } from 'lucia';
import { hash } from '@node-rs/argon2';
import type { User } from '@prisma/client';
import type { Cookies } from '@sveltejs/kit';
import { lucia } from '$lib/server/auth';
import { database } from '../database';
const create_user = async (
username: string,
email: string,
password: string,
cookies?: Cookies,
role: 'user' | 'admin' | 'root' = 'user'
) => {
const [_, usernameTaken, emailTaken] = await database.users.one(username, email);
if (usernameTaken === true) return [null, USER_EXISTS] as [null, typeof USER_EXISTS];
if (emailTaken === true) return [null, EMAIL_EXISTS] as [null, typeof EMAIL_EXISTS];
const password_hash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const { password_hash: __, ...user } = await prisma.user.create({
data: { id: generateId(8), username, email, password_hash, role }
});
if (cookies) {
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, {
path: '.',
...sessionCookie.attributes
});
}
return [user, null] as [User, null];
};
export { create_user };

View File

@@ -0,0 +1,16 @@
import { prisma } from '$lib/server/prisma';
import { database } from '../database';
export const delete_user = async (
id?: string | null,
username?: string,
email?: string,
roles: ('admin' | 'root' | 'user')[] = ['admin', 'root']
) => {
if (!id) {
const [{ id: existingId }] = await database.users.one(username, email);
return await prisma.user.delete({
where: { id: id ?? existingId, role: { not: { in: roles } } }
});
} else return await prisma.user.delete({ where: { id } });
};

View File

@@ -0,0 +1,36 @@
import { prisma } from '$lib/server/prisma';
export const get_users = async (query?: string, limit: number = 10, offset: number = 0) => {
const users = await prisma.user.findMany({
where: {
OR: query
? [
{
username: {
contains: query
}
},
{
email: {
contains: query
}
},
{}
]
: undefined
},
include: {
_count: {
select: {
snapps: true
}
}
},
take: limit,
skip: offset
});
const count = await prisma.user.count();
return [users, count] as [typeof users, number];
};

View File

@@ -0,0 +1,12 @@
import { prisma } from '$lib/server/prisma';
import type { User } from 'lucia';
export const get_one = async (username?: string, email?: string) => {
const user = await prisma.user.findFirst({ where: { OR: [{ username }, { email }] } });
return [user, user?.username === username || null, user?.email === email || null] as [
User|null,
boolean,
boolean
];
};

View File

@@ -0,0 +1,7 @@
import { prisma } from "$lib/server/prisma";
export const get_one_by_id = async (id: string) => {
const user = await prisma.user.findFirst({ where: { id } });
return user
};

View File

@@ -0,0 +1,6 @@
import { prisma } from '$lib/server/prisma';
export const is_admin = async (userId: string) => {
const role = (await prisma.user.findFirst({ where: { id: userId } }))?.role || 'user';
return ['admin', 'root'].includes(role);
};

View File

@@ -0,0 +1,22 @@
import { prisma } from '$lib/server/prisma';
import { TimeSpan, createDate } from 'oslo';
import { sha256 } from 'oslo/crypto';
import { encodeHex } from 'oslo/encoding';
import { generateIdFromEntropySize } from 'lucia';
async function createPasswordResetToken(userId: string): Promise<string> {
await prisma.password_reset.deleteMany({ where: { userId } });
const tokenId = generateIdFromEntropySize(25); // 40 character
const tokenHash = encodeHex(await sha256(new TextEncoder().encode(tokenId)));
await prisma.password_reset.create({
data: {
token_hash: tokenHash,
userId: userId,
expiresAt: createDate(new TimeSpan(2, 'h'))
}
});
return tokenId;
}
export { createPasswordResetToken };

View File

@@ -0,0 +1,18 @@
import { prisma } from '$lib/server/prisma';
import type { User } from '@prisma/client';
import { database } from '../database';
import { EMAIL_EXISTS, USER_DOES_NOT_EXISTS, USER_EXISTS } from '$lib/utils/constants';
export const update_user = async (payload: Partial<User>, id: string) => {
const existing = await database.users.id(
id
);
if (!existing) return [null, USER_DOES_NOT_EXISTS] as [null, typeof USER_DOES_NOT_EXISTS];
const [check_username] = await database.users.one(payload.username)
const [check_mail] = await database.users.one(undefined, payload.email)
if (check_username && check_username.id !== id) return [null, USER_EXISTS] as [null, typeof USER_EXISTS]
if (check_mail && check_mail.id !== id) return [null, EMAIL_EXISTS] as [null, typeof EMAIL_EXISTS]
const user = await prisma.user.update({ where: { id }, data: { ...payload } });
return [user, null] as [User, null];
};

View File

@@ -0,0 +1,8 @@
import { prisma } from '$lib/server/prisma';
export const check_watchlist = async (domain: string | null, username: string | null) => {
const res = await prisma.watchlist.findFirst({
where: { OR: [{ domain }, { username, domain }] }
});
return (res?.allowed || true) as boolean;
};

View File

@@ -0,0 +1,9 @@
import { prisma } from '$lib/server/prisma';
export const count_watchlisted = async (
allowed?: boolean,
username?: string | object,
domain?: string | object
) => {
return await prisma.watchlist.count({ where: { allowed, username, domain } });
};

View File

@@ -0,0 +1,5 @@
import { prisma } from '$lib/server/prisma';
export const delete_watchlist = async (id: string) => {
await prisma.watchlist.delete({ where: { id } });
};

View File

@@ -0,0 +1,15 @@
import { prisma } from '$lib/server/prisma';
export const get_list = async (
allowed?: boolean,
limit: number = 10,
offset: number = 0,
username?: string | object,
domain?: string | object
) => {
return await prisma.watchlist.findMany({
where: { allowed, domain, username },
take: limit,
skip: offset
});
};

Some files were not shown because too many files have changed in this diff Show More