mirror of
https://github.com/wireadmin/wireadmin
synced 2025-06-26 18:28:06 +00:00
init with Bun + SvelteKit
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.idea
|
||||
.ignore
|
||||
node_modules
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
FROM node:alpine as base
|
||||
WORKDIR /app
|
||||
FROM oven/bun:alpine as base
|
||||
LABEL Maintainer="Shahrad Elahi <https://github.com/shahradelahi>"
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENV TZ=UTC
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
COPY --from=golang:1.20-alpine /usr/local/go/ /usr/local/go/
|
||||
COPY --from=gogost/gost:3.0.0-rc8 /bin/gost /usr/local/bin/gost
|
||||
|
||||
COPY --from=chriswayg/tor-alpine:latest /usr/local/bin/obfs4proxy /usr/local/bin/obfs4proxy
|
||||
COPY --from=chriswayg/tor-alpine:latest /usr/local/bin/meek-server /usr/local/bin/meek-server
|
||||
|
||||
@@ -20,19 +18,35 @@ RUN apk add -U --no-cache \
|
||||
redis
|
||||
|
||||
|
||||
FROM base
|
||||
WORKDIR /app
|
||||
FROM base AS deps
|
||||
|
||||
COPY /src/ /app/
|
||||
COPY /config/torrc /etc/tor/torrc
|
||||
RUN mkdir -p /temp/dev
|
||||
COPY web/package.json web/bun.lockb /temp/dev/
|
||||
RUN cd /temp/dev && bun install --frozen-lockfile
|
||||
|
||||
RUN mkdir -p /temp/prod
|
||||
COPY web/package.json web/bun.lockb /temp/prod/
|
||||
RUN cd /temp/prod && bun install --frozen-lockfile --production
|
||||
|
||||
|
||||
FROM install AS build
|
||||
COPY --from=deps /temp/dev/node_modules node_modules
|
||||
COPY web .
|
||||
|
||||
# build
|
||||
ENV NODE_ENV=production
|
||||
RUN bun run build
|
||||
|
||||
|
||||
FROM base AS release
|
||||
|
||||
COPY --from=deps /temp/prod/node_modules node_modules
|
||||
COPY --from=build /usr/src/app/build .
|
||||
COPY --from=build /usr/src/app/package.json .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# run the app
|
||||
USER bun
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://127.0.0.1:3000/api/healthcheck || exit 1
|
||||
|
||||
COPY docker-entrypoint.sh /usr/bin/entrypoint
|
||||
RUN chmod +x /usr/bin/entrypoint
|
||||
ENTRYPOINT ["/usr/bin/entrypoint"]
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
CMD [ "bun", "start" ]
|
||||
@@ -3,14 +3,10 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"dev:image": "cross-env DOCKER_BUILDKIT=1 docker build --tag wireadmin -f Dockerfile-Dev .",
|
||||
"dev:image": "DOCKER_BUILDKIT=1 docker build --tag wireadmin -f Dockerfile-Dev .",
|
||||
"dev": "docker-compose -f docker-compose.yml -f docker-compose.dev.yml up; docker rm -f wireadmin"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Shahrad Elahi <https://github.com/shahradelahi>",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@8.7.0",
|
||||
"dependencies": {
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -1,58 +0,0 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
|
||||
packages:
|
||||
|
||||
/cross-env@7.0.3:
|
||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
cross-spawn: 7.0.3
|
||||
dev: false
|
||||
|
||||
/cross-spawn@7.0.3:
|
||||
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
|
||||
engines: {node: '>= 8'}
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
dev: false
|
||||
|
||||
/isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
dev: false
|
||||
|
||||
/path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
dev: false
|
||||
|
||||
/shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
dev: false
|
||||
|
||||
/shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
dev: false
|
||||
|
||||
/which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
dev: false
|
||||
34
src/.gitignore
vendored
34
src/.gitignore
vendored
@@ -1,34 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -1 +0,0 @@
|
||||
auto-install-peers = true
|
||||
@@ -1,28 +0,0 @@
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello).
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
@@ -1,298 +0,0 @@
|
||||
import type {
|
||||
Adapter,
|
||||
AdapterUser,
|
||||
AdapterAccount,
|
||||
AdapterSession,
|
||||
VerificationToken,
|
||||
} from "next-auth/adapters"
|
||||
import type Redis from "ioredis"
|
||||
|
||||
/** This is the interface of the Upstash Redis adapter options. */
|
||||
export interface RedisAdapterOptions {
|
||||
/**
|
||||
* The base prefix for your keys
|
||||
*/
|
||||
baseKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `account` key
|
||||
*/
|
||||
accountKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `accountByUserId` key
|
||||
*/
|
||||
accountByUserIdPrefix?: string
|
||||
/**
|
||||
* The prefix for the `emailKey` key
|
||||
*/
|
||||
emailKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `sessionKey` key
|
||||
*/
|
||||
sessionKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `sessionByUserId` key
|
||||
*/
|
||||
sessionByUserIdKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `user` key
|
||||
*/
|
||||
userKeyPrefix?: string
|
||||
/**
|
||||
* The prefix for the `verificationToken` key
|
||||
*/
|
||||
verificationTokenKeyPrefix?: string
|
||||
}
|
||||
|
||||
export const defaultOptions = {
|
||||
baseKeyPrefix: "",
|
||||
accountKeyPrefix: "user:account:",
|
||||
accountByUserIdPrefix: "user:account:by-user-id:",
|
||||
emailKeyPrefix: "user:email:",
|
||||
sessionKeyPrefix: "user:session:",
|
||||
sessionByUserIdKeyPrefix: "user:session:by-user-id:",
|
||||
userKeyPrefix: "user:",
|
||||
verificationTokenKeyPrefix: "user:token:",
|
||||
}
|
||||
|
||||
const isoDateRE =
|
||||
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/
|
||||
function isDate(value: any) {
|
||||
return value && isoDateRE.test(value) && !isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
export function hydrateDates(json: object) {
|
||||
return Object.entries(json).reduce((acc, [key, val]) => {
|
||||
acc[key] = isDate(val) ? new Date(val as string) : val
|
||||
return acc
|
||||
}, {} as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* ## Setup
|
||||
*
|
||||
* Configure Auth.js to use the Upstash Redis Adapter:
|
||||
*
|
||||
* ```javascript title="pages/api/auth/[...nextauth].js"
|
||||
* import NextAuth from "next-auth"
|
||||
* import GoogleProvider from "next-auth/providers/google"
|
||||
* import { RedisAdapter } from "@shahrad/redis-adapter"
|
||||
* import redisClient from "ioredis";
|
||||
*
|
||||
* const redis = redisClient(
|
||||
* process.env.UPSTASH_REDIS_URL,
|
||||
* process.env.UPSTASH_REDIS_TOKEN
|
||||
* )
|
||||
*
|
||||
* export default NextAuth({
|
||||
* adapter: RedisAdapter(redis),
|
||||
* providers: [
|
||||
* GoogleProvider({
|
||||
* clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
* clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
* }),
|
||||
* ],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* ## Advanced usage
|
||||
*
|
||||
* ### Using multiple apps with a single Upstash Redis instance
|
||||
*
|
||||
* The Upstash free-tier allows for only one Redis instance. If you have multiple Auth.js connected apps using this instance, you need different key prefixes for every app.
|
||||
*
|
||||
* You can change the prefixes by passing an `options` object as the second argument to the adapter factory function.
|
||||
*
|
||||
* The default values for this object are:
|
||||
*
|
||||
* ```js
|
||||
* const defaultOptions = {
|
||||
* baseKeyPrefix: "",
|
||||
* accountKeyPrefix: "user:account:",
|
||||
* accountByUserIdPrefix: "user:account:by-user-id:",
|
||||
* emailKeyPrefix: "user:email:",
|
||||
* sessionKeyPrefix: "user:session:",
|
||||
* sessionByUserIdKeyPrefix: "user:session:by-user-id:",
|
||||
* userKeyPrefix: "user:",
|
||||
* verificationTokenKeyPrefix: "user:token:",
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Usually changing the `baseKeyPrefix` should be enough for this scenario, but for more custom setups, you can also change the prefixes of every single key.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* ```js
|
||||
* export default NextAuth({
|
||||
* ...
|
||||
* adapter: RedisAdapter(redis, {baseKeyPrefix: "app2:"})
|
||||
* ...
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function RedisAdapter(
|
||||
client: Redis,
|
||||
options: RedisAdapterOptions = {}
|
||||
): Adapter {
|
||||
const mergedOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
}
|
||||
|
||||
const { baseKeyPrefix } = mergedOptions
|
||||
const accountKeyPrefix = baseKeyPrefix + mergedOptions.accountKeyPrefix
|
||||
const accountByUserIdPrefix =
|
||||
baseKeyPrefix + mergedOptions.accountByUserIdPrefix
|
||||
const emailKeyPrefix = baseKeyPrefix + mergedOptions.emailKeyPrefix
|
||||
const sessionKeyPrefix = baseKeyPrefix + mergedOptions.sessionKeyPrefix
|
||||
const sessionByUserIdKeyPrefix =
|
||||
baseKeyPrefix + mergedOptions.sessionByUserIdKeyPrefix
|
||||
const userKeyPrefix = baseKeyPrefix + mergedOptions.userKeyPrefix
|
||||
const verificationTokenKeyPrefix =
|
||||
baseKeyPrefix + mergedOptions.verificationTokenKeyPrefix
|
||||
|
||||
const setObjectAsJson = async (key: string, obj: any) =>
|
||||
await client.set(key, JSON.stringify(obj))
|
||||
|
||||
const setAccount = async (id: string, account: AdapterAccount) => {
|
||||
const accountKey = accountKeyPrefix + id
|
||||
await setObjectAsJson(accountKey, account)
|
||||
await client.set(accountByUserIdPrefix + account.userId, accountKey)
|
||||
return account
|
||||
}
|
||||
|
||||
const getAccount = async (id: string) => {
|
||||
const account = await client.get(accountKeyPrefix + id) as AdapterSession | null
|
||||
if (!account) return null
|
||||
return hydrateDates(account)
|
||||
}
|
||||
|
||||
const setSession = async (
|
||||
id: string,
|
||||
session: AdapterSession
|
||||
): Promise<AdapterSession> => {
|
||||
const sessionKey = sessionKeyPrefix + id
|
||||
await setObjectAsJson(sessionKey, session)
|
||||
await client.set(sessionByUserIdKeyPrefix + session.userId, sessionKey)
|
||||
return session
|
||||
}
|
||||
|
||||
const getSession = async (id: string) => {
|
||||
const session = await client.get(sessionKeyPrefix + id) as AdapterSession | null
|
||||
if (!session) return null
|
||||
return hydrateDates(session)
|
||||
}
|
||||
|
||||
const setUser = async (
|
||||
id: string,
|
||||
user: AdapterUser
|
||||
): Promise<AdapterUser> => {
|
||||
await setObjectAsJson(userKeyPrefix + id, user)
|
||||
await client.set(`${emailKeyPrefix}${user.email as string}`, id)
|
||||
return user
|
||||
}
|
||||
|
||||
const getUser = async (id: string) => {
|
||||
const user = await client.get(userKeyPrefix + id) as AdapterUser | null
|
||||
if (!user) return null
|
||||
return hydrateDates(user)
|
||||
}
|
||||
|
||||
return {
|
||||
async createUser(user) {
|
||||
const id = crypto.randomUUID()
|
||||
// TypeScript thinks the emailVerified field is missing
|
||||
// but all fields are copied directly from user, so it's there
|
||||
return await setUser(id, { ...user, id })
|
||||
},
|
||||
getUser,
|
||||
async getUserByEmail(email) {
|
||||
const userId = await client.get(emailKeyPrefix + email)
|
||||
if (!userId) {
|
||||
return null
|
||||
}
|
||||
return await getUser(userId)
|
||||
},
|
||||
async getUserByAccount(account) {
|
||||
const dbAccount = await getAccount(
|
||||
`${account.provider}:${account.providerAccountId}`
|
||||
)
|
||||
if (!dbAccount) return null
|
||||
return await getUser(dbAccount.userId)
|
||||
},
|
||||
async updateUser(updates) {
|
||||
const userId = updates.id as string
|
||||
const user = await getUser(userId)
|
||||
return await setUser(userId, { ...(user as AdapterUser), ...updates })
|
||||
},
|
||||
async linkAccount(account) {
|
||||
const id = `${account.provider}:${account.providerAccountId}`
|
||||
return await setAccount(id, { ...account, id })
|
||||
},
|
||||
createSession: (session) => setSession(session.sessionToken, session),
|
||||
async getSessionAndUser(sessionToken) {
|
||||
const session = await getSession(sessionToken)
|
||||
if (!session) return null
|
||||
const user = await getUser(session.userId)
|
||||
if (!user) return null
|
||||
return { session, user }
|
||||
},
|
||||
async updateSession(updates) {
|
||||
const session = await getSession(updates.sessionToken)
|
||||
if (!session) return null
|
||||
return await setSession(updates.sessionToken, { ...session, ...updates })
|
||||
},
|
||||
async deleteSession(sessionToken) {
|
||||
await client.del(sessionKeyPrefix + sessionToken)
|
||||
},
|
||||
async createVerificationToken(verificationToken) {
|
||||
await setObjectAsJson(
|
||||
verificationTokenKeyPrefix +
|
||||
verificationToken.identifier +
|
||||
":" +
|
||||
verificationToken.token,
|
||||
verificationToken
|
||||
)
|
||||
return verificationToken
|
||||
},
|
||||
async useVerificationToken(verificationToken) {
|
||||
const tokenKey =
|
||||
verificationTokenKeyPrefix +
|
||||
verificationToken.identifier +
|
||||
":" +
|
||||
verificationToken.token
|
||||
|
||||
const token = await client.get(tokenKey) as VerificationToken | null
|
||||
if (!token) return null
|
||||
|
||||
await client.del(tokenKey)
|
||||
return hydrateDates(token)
|
||||
// return reviveFromJson(token)
|
||||
},
|
||||
async unlinkAccount(account) {
|
||||
const id = `${account.provider}:${account.providerAccountId}`
|
||||
const dbAccount = await getAccount(id)
|
||||
if (!dbAccount) return
|
||||
const accountKey = `${accountKeyPrefix}${id}`
|
||||
await client.del(
|
||||
accountKey,
|
||||
`${accountByUserIdPrefix} + ${dbAccount.userId as string}`
|
||||
)
|
||||
},
|
||||
async deleteUser(userId) {
|
||||
const user = await getUser(userId)
|
||||
if (!user) return
|
||||
const accountByUserKey = accountByUserIdPrefix + userId
|
||||
const accountKey = await client.get(accountByUserKey)
|
||||
const sessionByUserIdKey = sessionByUserIdKeyPrefix + userId
|
||||
const sessionKey = await client.get(sessionByUserIdKey)
|
||||
await client.del(
|
||||
userKeyPrefix + userId,
|
||||
`${emailKeyPrefix}${user.email as string}`,
|
||||
accountKey as string,
|
||||
accountByUserKey,
|
||||
sessionKey as string,
|
||||
sessionByUserIdKey
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export const WG_PATH = '/etc/wireguard'
|
||||
|
||||
export const WG_PRE_UP = process.env.WG_PRE_UP || ''
|
||||
|
||||
export const WG_POST_UP = process.env.WG_POST_UP || ''
|
||||
|
||||
export const WG_PRE_DOWN = process.env.WG_PRE_DOWN || ''
|
||||
|
||||
export const WG_POST_DOWN = process.env.WG_POST_DOWN || ''
|
||||
|
||||
export const IPV4_REGEX = new RegExp(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)
|
||||
@@ -1,34 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default class FileManager {
|
||||
|
||||
static readDirectoryFiles(dir: string): string[] {
|
||||
const files_: string[] = [];
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const i in files) {
|
||||
const name = dir + '/' + files[i];
|
||||
if (!fs.statSync(name).isDirectory()) {
|
||||
files_.push(path.resolve(process.cwd(), name))
|
||||
}
|
||||
}
|
||||
return files_;
|
||||
}
|
||||
|
||||
static readFile(filePath: string): string {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('file not found')
|
||||
}
|
||||
return fs.readFileSync(filePath, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
static writeFile(filePath: string, content: string, forced: boolean = false): void {
|
||||
const dir_ = filePath.split('/')
|
||||
const dir = dir_.slice(0, dir_.length - 1).join('/')
|
||||
if (!fs.existsSync(dir) && forced) {
|
||||
fs.mkdirSync(dir, { mode: 0o744 })
|
||||
}
|
||||
fs.writeFileSync(filePath, content,{ encoding: 'utf8' })
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NameSchema } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
import type { Rule } from "rc-field-form/lib/interface";
|
||||
|
||||
export const RLS_NAME_INPUT: Rule[] = [
|
||||
{
|
||||
required: true,
|
||||
message: 'Name is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
import Shell from "@lib/shell";
|
||||
|
||||
export default class Network {
|
||||
|
||||
public static async createInterface(inet: string, address: string): Promise<boolean> {
|
||||
// First, check if the interface already exists.
|
||||
const interfaces = await Shell.exec(`ip link show | grep ${inet}`, true)
|
||||
if (interfaces.includes(`${inet}`)) {
|
||||
console.error(`failed to create interface, ${inet} already exists!`)
|
||||
return false
|
||||
}
|
||||
|
||||
const o2 = await Shell.exec(`ip address add dev ${inet} ${address}`)
|
||||
// check if it has any error
|
||||
if (o2 !== '') {
|
||||
console.error(`failed to assign ip to interface, ${o2}`)
|
||||
console.log(`removing interface ${inet} due to errors`)
|
||||
await Shell.exec(`ip link delete dev ${inet}`, true)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public static async dropInterface(inet: string) {
|
||||
await Shell.exec(`ip link delete dev ${inet}`, true)
|
||||
}
|
||||
|
||||
public static async defaultInterface(): Promise<string> {
|
||||
return await Shell.exec(`ip route list default | awk '{print $5}'`)
|
||||
}
|
||||
|
||||
public static async checkInterfaceExists(inet: string): Promise<boolean> {
|
||||
return await Shell.exec(`ip link show | grep ${inet}`, true)
|
||||
.then((o) => o.trim() !== '')
|
||||
}
|
||||
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import IORedis from "ioredis";
|
||||
|
||||
export const client = new IORedis({
|
||||
port: 6479
|
||||
});
|
||||
|
||||
export type RedisClient = typeof client;
|
||||
|
||||
export const WG_SEVER_PATH = `WG::SERVERS`
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NextApiResponse } from "next";
|
||||
|
||||
export default async function safeServe(res: NextApiResponse, fn: () => void): Promise<void> {
|
||||
return new Promise(() => {
|
||||
try {
|
||||
fn()
|
||||
} catch (e) {
|
||||
console.error('[SafeServe]: ', e)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Server Internal Error' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { IPV4_REGEX } from "@lib/constants";
|
||||
import { isBetween, isPrivateIP } from "@lib/utils";
|
||||
|
||||
export const NameSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => v.length < 32, {
|
||||
message: 'Name must be less than 32 characters'
|
||||
})
|
||||
.refine((v) => v.match(/^[a-zA-Z0-9-_]+$/), {
|
||||
message: 'Name must only contain alphanumeric characters, dashes, and underscores'
|
||||
})
|
||||
|
||||
export const AddressSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => isPrivateIP(v), {
|
||||
message: 'Address must be a private IP address'
|
||||
})
|
||||
|
||||
export const PortSchema = z
|
||||
.string()
|
||||
.nonempty()
|
||||
.refine((v) => {
|
||||
const port = parseInt(v)
|
||||
return port > 0 && port < 65535
|
||||
}, {
|
||||
message: 'Port must be a valid port number'
|
||||
})
|
||||
|
||||
export const TypeSchema = z.enum([ 'direct', 'tor' ])
|
||||
|
||||
export const DnsSchema = z
|
||||
.string()
|
||||
.regex(IPV4_REGEX, {
|
||||
message: 'DNS must be a valid IPv4 address'
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const MtuSchema = z
|
||||
.string()
|
||||
.refine((d) => isBetween(d, 1, 1500), {
|
||||
message: 'MTU must be between 1 and 1500'
|
||||
})
|
||||
.optional()
|
||||
|
||||
export const ServerId = z
|
||||
.string()
|
||||
.uuid({ message: 'Server ID must be a valid UUID' })
|
||||
|
||||
export const ClientId = z
|
||||
.string()
|
||||
.uuid({ message: 'Client ID must be a valid UUID' })
|
||||
|
||||
export const ServerStatusSchema = z
|
||||
.enum([ 'up', 'down' ], {
|
||||
errorMap: issue => {
|
||||
switch (issue.code) {
|
||||
case 'invalid_type':
|
||||
case 'invalid_enum_value':
|
||||
return { message: 'Status must be either "up" or "down"' }
|
||||
default:
|
||||
return { message: 'Invalid status' }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
export default class ServerError extends Error {
|
||||
|
||||
statusCode
|
||||
|
||||
constructor(message: string, statusCode: number = 500) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
import childProcess from "child_process";
|
||||
|
||||
export default class Shell {
|
||||
|
||||
public static async exec(command: string, safe: boolean = false, ...args: string[]): Promise<string> {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('This program is not meant to run non UNIX systems');
|
||||
}
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const cmd = `${command}${args.length > 0 ? ` ${args.join(' ')}` : ''}`;
|
||||
childProcess.exec(
|
||||
cmd,
|
||||
{ shell: 'bash' },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error(
|
||||
`${safe ? 'Ignored::' : 'CRITICAL::'} Shell Command Failed:`,
|
||||
JSON.stringify({ cmd, code: err.code, killed: err.killed, stderr })
|
||||
);
|
||||
return safe ? resolve(stderr) : reject(err);
|
||||
}
|
||||
return resolve(String(stdout).trim());
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { APIResponse, Peer, PeerSchema, WgServer, WgServerSchema } from "@lib/typings";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
|
||||
export const UPDATE_SERVER = async (url: string, { arg }: { arg: Partial<WgServer> }) => {
|
||||
const parsed = WgServerSchema.partial().safeParse(arg)
|
||||
if (!parsed.success) {
|
||||
console.error('invalid server schema', zodErrorMessage(parsed.error))
|
||||
return false
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const UPDATE_CLIENT = async (url: string, { arg }: { arg: Partial<Peer> }) => {
|
||||
const parsed = PeerSchema
|
||||
.partial()
|
||||
.safeParse(arg)
|
||||
if (!parsed.success) {
|
||||
console.error('invalid peer schema', zodErrorMessage(parsed.error))
|
||||
return false
|
||||
}
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import type React from "react";
|
||||
import { IPV4_REGEX } from "@lib/constants";
|
||||
import { NextApiRequest as TNextApiRequest } from "next/dist/shared/lib/utils";
|
||||
import { NameSchema } from "@lib/schemas/WireGuard";
|
||||
|
||||
export const WgKeySchema = z.object({
|
||||
privateKey: z.string(),
|
||||
publicKey: z.string(),
|
||||
preSharedKey: z.string(),
|
||||
})
|
||||
|
||||
export type WgKey = z.infer<typeof WgKeySchema>
|
||||
|
||||
const WgPeerSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().regex(/^[A-Za-z\d\s]{3,32}$/),
|
||||
preSharedKey: z.string(),
|
||||
endpoint: z.string(),
|
||||
address: z.string().regex(IPV4_REGEX),
|
||||
latestHandshakeAt: z.string().nullable(),
|
||||
transferRx: z.number().nullable(),
|
||||
transferTx: z.number().nullable(),
|
||||
persistentKeepalive: z.number().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.merge(WgKeySchema)
|
||||
|
||||
export type WgPeer = z.infer<typeof WgPeerSchema>
|
||||
|
||||
export const PeerSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: NameSchema,
|
||||
preSharedKey: z.string().nullable(),
|
||||
allowedIps: z.string().regex(IPV4_REGEX),
|
||||
persistentKeepalive: z.number().nullable(),
|
||||
})
|
||||
.merge(WgKeySchema)
|
||||
|
||||
export type Peer = z.infer<typeof PeerSchema>
|
||||
|
||||
export const WgServerSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
confId: z.number(),
|
||||
confHash: z.string().nullable(),
|
||||
type: z.enum([ 'direct', 'bridge', 'tor' ]),
|
||||
name: NameSchema,
|
||||
address: z.string().regex(IPV4_REGEX),
|
||||
listen: z.number(),
|
||||
preUp: z.string().nullable(),
|
||||
postUp: z.string().nullable(),
|
||||
preDown: z.string().nullable(),
|
||||
postDown: z.string().nullable(),
|
||||
dns: z.string().regex(IPV4_REGEX).nullable(),
|
||||
peers: z.array(PeerSchema),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
status: z.enum([ 'up', 'down' ]),
|
||||
})
|
||||
.merge(WgKeySchema.omit({ preSharedKey: true }))
|
||||
|
||||
export type WgServer = z.infer<typeof WgServerSchema>
|
||||
|
||||
export type ReactHTMLProps<T extends HTMLElement> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>
|
||||
|
||||
export type APIErrorResponse = {
|
||||
ok: false
|
||||
details: string
|
||||
}
|
||||
|
||||
export type APISuccessResponse<D> = {
|
||||
ok: true
|
||||
result: D
|
||||
}
|
||||
|
||||
export type APIResponse<D, N extends boolean = false> = N extends true ? D : APIErrorResponse | APISuccessResponse<D>
|
||||
|
||||
export type NextApiRequest<
|
||||
P = Record<string, string | string[] | undefined>,
|
||||
Q = Record<string, string | string[] | undefined>
|
||||
> = Omit<TNextApiRequest, 'query'> & {
|
||||
query: Partial<Q> & P
|
||||
}
|
||||
|
||||
export type LeastOne<T, U = { [K in keyof T]: Pick<T, K> }> = Partial<T> & U[keyof U];
|
||||
@@ -1,45 +0,0 @@
|
||||
import { IPV4_REGEX } from "@/lib/constants";
|
||||
|
||||
export function isValidIPv4(str: string): boolean {
|
||||
return IPV4_REGEX.test(str);
|
||||
}
|
||||
|
||||
export function isBetween(v: any, n1: number, n2: number): boolean {
|
||||
if (Number.isNaN(v)) {
|
||||
return false
|
||||
}
|
||||
const n = Number(v)
|
||||
return n1 <= n && n >= n2
|
||||
}
|
||||
|
||||
export function isJson(str: string | object): boolean {
|
||||
if (typeof str === 'object' && isObject(str)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return typeof str === 'string' && JSON.parse(str);
|
||||
} catch (ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isObject(obj: object) {
|
||||
return Object.prototype.toString.call(obj) === '[object Object]';
|
||||
}
|
||||
|
||||
/**
|
||||
* Private IP Address Identifier in Regular Expression
|
||||
*
|
||||
* 127. 0.0.0 – 127.255.255.255 127.0.0.0 /8
|
||||
* 10. 0.0.0 – 10.255.255.255 10.0.0.0 /8
|
||||
* 172. 16.0.0 – 172. 31.255.255 172.16.0.0 /12
|
||||
* 192.168.0.0 – 192.168.255.255 192.168.0.0 /16
|
||||
*/
|
||||
export function isPrivateIP(ip: string) {
|
||||
const ipRegex = /^(127\.)|(10\.)|(172\.1[6-9]\.)|(172\.2[0-9]\.)|(172\.3[0-1]\.)|(192\.168\.)/
|
||||
return ipRegex.test(ip)
|
||||
}
|
||||
|
||||
export function dynaJoin(lines: (string | 0 | null | undefined)[]): string[] {
|
||||
return lines.filter((d) => typeof d === 'string') as string[]
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { WgServer } from "@lib/typings";
|
||||
|
||||
type Peer = WgServer['peers'][0]
|
||||
|
||||
interface GenPeerConParams extends Peer, Pick<WgServer, 'dns'> {
|
||||
serverAddress?: string
|
||||
serverPublicKey: string
|
||||
port: number
|
||||
}
|
||||
|
||||
|
||||
export async function getServerIP(): Promise<string> {
|
||||
const resp = await fetch('/api/host')
|
||||
return resp.text()
|
||||
}
|
||||
|
||||
export async function getPeerConf(params: GenPeerConParams): Promise<string> {
|
||||
const serverAddress = params.serverAddress || await getServerIP()
|
||||
const lines = [
|
||||
'# Autogenerated by WireGuard UI (WireAdmin)',
|
||||
'[Interface]',
|
||||
`PrivateKey = ${params.privateKey}`,
|
||||
`Address = ${params.allowedIps}/24`,
|
||||
`${params.dns ? `DNS = ${params.dns}` : 'OMIT'}`,
|
||||
'',
|
||||
'[Peer]',
|
||||
`PublicKey = ${params.serverPublicKey}`,
|
||||
`${params.preSharedKey ? `PresharedKey = ${params.preSharedKey}` : 'OMIT'}`,
|
||||
`AllowedIPs = 0.0.0.0/0, ::/0`,
|
||||
`PersistentKeepalive = ${params.persistentKeepalive}`,
|
||||
`Endpoint = ${serverAddress}:${params.port}`,
|
||||
]
|
||||
return lines
|
||||
.filter((l) => l !== 'OMIT')
|
||||
.join('\n')
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { WG_PATH } from "@lib/constants";
|
||||
import Shell from "@lib/shell";
|
||||
import { Peer, WgKey, WgServer } from "@lib/typings";
|
||||
import { client, WG_SEVER_PATH } from "@lib/redis";
|
||||
import { dynaJoin, isJson } from "@lib/utils";
|
||||
import deepmerge from "deepmerge";
|
||||
import { getPeerConf } from "@lib/wireguard-utils";
|
||||
import Network from "@lib/network";
|
||||
import { enc, SHA256 } from "crypto-js";
|
||||
|
||||
export class WGServer {
|
||||
|
||||
static async stop(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
if (await Network.checkInterfaceExists(`wg${server.confId}`)) {
|
||||
await Shell.exec(`wg-quick down wg${server.confId}`, true)
|
||||
}
|
||||
|
||||
await this.update(id, { status: 'down' })
|
||||
return true
|
||||
}
|
||||
|
||||
static async start(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
const HASH = await getConfigHash(server.confId);
|
||||
if (!HASH || server.confHash !== HASH) {
|
||||
await writeConfigFile(server);
|
||||
await WGServer.update(id, { confHash: await getConfigHash(server.confId) });
|
||||
}
|
||||
|
||||
if (await Network.checkInterfaceExists(`wg${server.confId}`)) {
|
||||
await Shell.exec(`wg-quick down wg${server.confId}`, true)
|
||||
}
|
||||
|
||||
await Shell.exec(`wg-quick up wg${server.confId}`)
|
||||
|
||||
await this.update(id, { status: 'up' })
|
||||
return true
|
||||
}
|
||||
|
||||
static async remove(id: string): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
await this.stop(id)
|
||||
await fs.unlink(path.join(WG_PATH, `wg${server.confId}.conf`)).catch(() => null)
|
||||
|
||||
const index = await findServerIndex(id)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
|
||||
const element = await client.lindex(WG_SEVER_PATH, index)
|
||||
if (!element) {
|
||||
console.warn('remove: element not found')
|
||||
return true
|
||||
}
|
||||
|
||||
await client.lrem(WG_SEVER_PATH, 1, element)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static async update(id: string, update: Partial<WgServer>): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
const index = await findServerIndex(id)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
const res = await client.lset(WG_SEVER_PATH, index, JSON.stringify({
|
||||
...deepmerge(server, update),
|
||||
peers: update?.peers || server?.peers || [],
|
||||
updatedAt: new Date().toISOString()
|
||||
}))
|
||||
return res === 'OK'
|
||||
}
|
||||
|
||||
static async findAttachedUuid(confId: number): Promise<string | undefined> {
|
||||
const server = await getServers()
|
||||
return server.find((s) => s.confId === confId)?.id
|
||||
}
|
||||
|
||||
static async addPeer(id: string, peer: WgServer['peers'][0]): Promise<boolean> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
const confPath = path.join(WG_PATH, `wg${server.confId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
const lines = conf.split('\n')
|
||||
|
||||
lines.push(...dynaJoin([
|
||||
`[Peer]`,
|
||||
`PublicKey = ${peer.publicKey}`,
|
||||
peer.preSharedKey && `PresharedKey = ${peer.preSharedKey}`,
|
||||
`AllowedIPs = ${peer.allowedIps}/32`,
|
||||
peer.persistentKeepalive && `PersistentKeepalive = ${peer.persistentKeepalive}`
|
||||
]))
|
||||
await fs.writeFile(confPath, lines.join('\n'))
|
||||
await WGServer.update(id, { confHash: await getConfigHash(server.confId) });
|
||||
|
||||
const index = await findServerIndex(id)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
await client.lset(WG_SEVER_PATH, index, JSON.stringify({
|
||||
...server,
|
||||
peers: [ ...server.peers, peer ]
|
||||
}))
|
||||
|
||||
await this.stop(server.id)
|
||||
await this.start(server.id)
|
||||
return true
|
||||
}
|
||||
|
||||
static async removePeer(serverId: string, publicKey: string): Promise<boolean> {
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return false
|
||||
}
|
||||
const peers = await wgPeersStr(server.confId)
|
||||
|
||||
const index = await findServerIndex(serverId)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
await client.lset(WG_SEVER_PATH, index, JSON.stringify({
|
||||
...server,
|
||||
peers: server.peers.filter((p) => p.publicKey !== publicKey)
|
||||
}))
|
||||
|
||||
const peerIndex = peers.findIndex((p) => p.includes(`PublicKey = ${publicKey}`))
|
||||
if (peerIndex === -1) {
|
||||
console.warn('removePeer: no peer found')
|
||||
return false
|
||||
}
|
||||
|
||||
const confPath = path.join(WG_PATH, `wg${server.confId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
const serverConfStr = conf.includes('[Peer]') ?
|
||||
conf.split('[Peer]')[0] :
|
||||
conf
|
||||
const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n')
|
||||
await fs.writeFile(confPath, `${serverConfStr}\n${peersStr}`)
|
||||
await WGServer.update(server.id, { confHash: await getConfigHash(server.confId) });
|
||||
|
||||
await WGServer.stop(server.id)
|
||||
await WGServer.start(server.id)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
static async updatePeer(serverId: string, publicKey: string, update: Partial<Peer>): Promise<boolean> {
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
console.error('WGServer:UpdatePeer: server could not be updated (Reason: not exists)')
|
||||
return false
|
||||
}
|
||||
|
||||
const index = await findServerIndex(serverId)
|
||||
if (typeof index !== 'number') {
|
||||
console.warn('findServerIndex: index not found')
|
||||
return true
|
||||
}
|
||||
|
||||
const updatedPeers = server.peers.map((p) => {
|
||||
if (p.publicKey !== publicKey) return p
|
||||
return deepmerge(p, update)
|
||||
})
|
||||
|
||||
await client.lset(WG_SEVER_PATH, index, JSON.stringify({ ...server, peers: updatedPeers }))
|
||||
await this.storePeers({ id: server.id, confId: server.confId }, publicKey, updatedPeers)
|
||||
|
||||
await WGServer.stop(serverId)
|
||||
await WGServer.start(serverId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private static async getPeerIndex(id: string, publicKey: string): Promise<number | undefined> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('server could not be updated (reason: not exists)')
|
||||
return undefined
|
||||
}
|
||||
return server.peers.findIndex((p) => p.publicKey === publicKey)
|
||||
}
|
||||
|
||||
private static async storePeers(sd: Pick<WgServer, 'id' | 'confId'>, publicKey: string, peers: Peer[]): Promise<void> {
|
||||
|
||||
const peerIndex = await this.getPeerIndex(sd.id, publicKey)
|
||||
if (peerIndex === -1) {
|
||||
console.warn('WGServer:StorePeers: no peer found')
|
||||
return
|
||||
}
|
||||
|
||||
const confPath = path.join(WG_PATH, `wg${sd.confId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
const serverConfStr = conf.includes('[Peer]') ?
|
||||
conf.split('[Peer]')[0] :
|
||||
conf
|
||||
|
||||
const peersStr = peers.filter((_, i) => i !== peerIndex).join('\n')
|
||||
await fs.writeFile(confPath, `${serverConfStr}\n${peersStr}`)
|
||||
await WGServer.update(sd.id, { confHash: await getConfigHash(sd.confId) });
|
||||
}
|
||||
|
||||
static async getFreePeerIp(id: string): Promise<string | undefined> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('getFreePeerIp: server not found')
|
||||
return undefined
|
||||
}
|
||||
const reservedIps = server.peers.map((p) => p.allowedIps)
|
||||
const ips = reservedIps.map((ip) => ip.split('/')[0])
|
||||
const net = server.address.split('/')[0].split('.')
|
||||
for (let i = 1; i < 255; i++) {
|
||||
const ip = `${net[0]}.${net[1]}.${net[2]}.${i}`
|
||||
if (!ips.includes(ip) && ip !== server.address.split('/')[0]) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
console.error('getFreePeerIp: no free ip found')
|
||||
return undefined
|
||||
}
|
||||
|
||||
static async generatePeerConfig(id: string, peerId: string): Promise<string | undefined> {
|
||||
const server = await findServer(id)
|
||||
if (!server) {
|
||||
console.error('generatePeerConfig: server not found')
|
||||
return undefined
|
||||
}
|
||||
const peer = server.peers.find((p) => p.id === peerId)
|
||||
if (!peer) {
|
||||
console.error('generatePeerConfig: peer not found')
|
||||
return undefined
|
||||
}
|
||||
return await getPeerConf({
|
||||
...peer,
|
||||
serverPublicKey: server.publicKey,
|
||||
port: server.listen,
|
||||
dns: server.dns
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is for checking out WireGuard server is running
|
||||
*/
|
||||
async function wgCheckout(configId: number): Promise<boolean> {
|
||||
const res = await Shell.exec(`ip link show | grep wg${configId}`, true)
|
||||
return res.includes(`wg${configId}`)
|
||||
}
|
||||
|
||||
export async function readWgConf(configId: number): Promise<WgServer> {
|
||||
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
const lines = conf.split('\n')
|
||||
const server: WgServer = {
|
||||
id: crypto.randomUUID(),
|
||||
confId: configId,
|
||||
confHash: null,
|
||||
type: 'direct',
|
||||
name: '',
|
||||
address: '',
|
||||
listen: 0,
|
||||
dns: null,
|
||||
privateKey: '',
|
||||
publicKey: '',
|
||||
preUp: null,
|
||||
preDown: null,
|
||||
postDown: null,
|
||||
postUp: null,
|
||||
peers: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: 'down'
|
||||
}
|
||||
let reachedPeers = false
|
||||
for (const line of lines) {
|
||||
const [ key, value ] = line.split('=').map((s) => s.trim())
|
||||
if (reachedPeers) {
|
||||
if (key === '[Peer]') {
|
||||
server.peers.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: `Unknown #${server.peers.length + 1}`,
|
||||
publicKey: '',
|
||||
privateKey: '', // it's okay to be empty because, we not using it on server
|
||||
preSharedKey: '',
|
||||
allowedIps: '',
|
||||
persistentKeepalive: null
|
||||
})
|
||||
}
|
||||
if (key === 'PublicKey') {
|
||||
server.peers[server.peers.length - 1].publicKey = value
|
||||
}
|
||||
if (key === 'PresharedKey') {
|
||||
server.peers[server.peers.length - 1].preSharedKey = value
|
||||
}
|
||||
if (key === 'AllowedIPs') {
|
||||
server.peers[server.peers.length - 1].allowedIps = value
|
||||
}
|
||||
if (key === 'PersistentKeepalive') {
|
||||
server.peers[server.peers.length - 1].persistentKeepalive = parseInt(value)
|
||||
}
|
||||
}
|
||||
if (key === 'PrivateKey') {
|
||||
server.privateKey = value
|
||||
}
|
||||
if (key === 'Address') {
|
||||
server.address = value
|
||||
}
|
||||
if (key === 'ListenPort') {
|
||||
server.listen = parseInt(value)
|
||||
}
|
||||
if (key === 'DNS') {
|
||||
server.dns = value
|
||||
}
|
||||
if (key === 'PreUp') {
|
||||
server.preUp = value
|
||||
}
|
||||
if (key === 'PreDown') {
|
||||
server.preDown = value
|
||||
}
|
||||
if (key === 'PostUp') {
|
||||
server.postUp = value
|
||||
}
|
||||
if (key === 'PostDown') {
|
||||
server.postDown = value
|
||||
}
|
||||
if (key === 'PublicKey') {
|
||||
server.publicKey = value
|
||||
}
|
||||
if (key === '[Peer]') {
|
||||
reachedPeers = true
|
||||
}
|
||||
}
|
||||
server.status = await wgCheckout(configId) ? 'up' : 'down'
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* This function checks if a WireGuard config exists in file system
|
||||
* @param configId
|
||||
*/
|
||||
async function wgConfExists(configId: number): Promise<boolean> {
|
||||
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
|
||||
try {
|
||||
await fs.access(confPath)
|
||||
return true
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to read /etc/wireguard/*.conf and sync them with our
|
||||
* redis server.
|
||||
*/
|
||||
async function syncServers(): Promise<boolean> {
|
||||
// get files in /etc/wireguard
|
||||
const files = await fs.readdir(WG_PATH)
|
||||
// filter files that start with wg and end with .conf
|
||||
const reg = new RegExp(/^wg(\d+)\.conf$/)
|
||||
const confs = files.filter((f) => reg.test(f))
|
||||
// read all confs
|
||||
const servers = await Promise.all(confs.map((f) => readWgConf(parseInt(f.match(reg)![1]))))
|
||||
// remove old servers
|
||||
await client.del(WG_SEVER_PATH)
|
||||
// save all servers to redis
|
||||
await client.lpush(WG_SEVER_PATH, ...servers.map((s) => JSON.stringify(s)))
|
||||
return true
|
||||
}
|
||||
|
||||
async function wgPeersStr(configId: number): Promise<string[]> {
|
||||
const confPath = path.join(WG_PATH, `wg${configId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
const rawPeers = conf.split('[Peer]')
|
||||
return rawPeers.slice(1).map((p) => `[Peer]\n${p}`)
|
||||
}
|
||||
|
||||
export async function generateWgKey(): Promise<WgKey> {
|
||||
const privateKey = await Shell.exec('wg genkey');
|
||||
const publicKey = await Shell.exec(`echo ${privateKey} | wg pubkey`);
|
||||
const preSharedKey = await Shell.exec('wg genkey');
|
||||
return { privateKey, publicKey, preSharedKey }
|
||||
}
|
||||
|
||||
export async function generateWgServer(config: {
|
||||
name: string
|
||||
address: string
|
||||
type: WgServer['type']
|
||||
port: number
|
||||
dns?: string
|
||||
mtu?: number
|
||||
insertDb?: boolean
|
||||
}): Promise<string> {
|
||||
|
||||
const { privateKey, publicKey } = await generateWgKey();
|
||||
|
||||
// inside redis create a config list
|
||||
const confId = await maxConfId() + 1
|
||||
const uuid = crypto.randomUUID()
|
||||
|
||||
let server: WgServer = {
|
||||
id: uuid,
|
||||
confId,
|
||||
confHash: null,
|
||||
type: config.type,
|
||||
name: config.name,
|
||||
address: config.address,
|
||||
listen: config.port,
|
||||
dns: config.dns || null,
|
||||
privateKey,
|
||||
publicKey,
|
||||
preUp: null,
|
||||
preDown: null,
|
||||
postDown: null,
|
||||
postUp: null,
|
||||
peers: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
status: 'up'
|
||||
}
|
||||
|
||||
// check if address or port are already reserved
|
||||
const [ addresses, ports ] = (await getServers())
|
||||
.map((s) => [ s.address, s.listen ])
|
||||
|
||||
// check for the conflict
|
||||
if (Array.isArray(addresses) && addresses.includes(config.address)) {
|
||||
throw new Error(`Address ${config.address} is already reserved!`)
|
||||
}
|
||||
|
||||
if (Array.isArray(ports) && ports.includes(config.port)) {
|
||||
throw new Error(`Port ${config.port} is already reserved!`)
|
||||
}
|
||||
|
||||
// setting iptables
|
||||
const iptables = await makeWgIptables(server)
|
||||
server.postUp = iptables.up
|
||||
server.postDown = iptables.down
|
||||
|
||||
// save server config
|
||||
if (false !== config.insertDb) {
|
||||
await client.lpush(WG_SEVER_PATH, JSON.stringify(server))
|
||||
}
|
||||
|
||||
const CONFIG_PATH = path.join(WG_PATH, `wg${confId}.conf`)
|
||||
|
||||
// save server config to disk
|
||||
await fs.writeFile(CONFIG_PATH, await genServerConf(server), { mode: 0o600 })
|
||||
|
||||
// updating hash of the config
|
||||
await WGServer.update(uuid, { confHash: await getConfigHash(confId) });
|
||||
|
||||
// to ensure interface does not exists
|
||||
await Shell.exec(`wg-quick down wg${confId}`, true)
|
||||
|
||||
// restart WireGuard
|
||||
await Shell.exec(`wg-quick up wg${confId}`)
|
||||
|
||||
// return server id
|
||||
return uuid
|
||||
}
|
||||
|
||||
export async function getConfigHash(confId: number): Promise<string | undefined> {
|
||||
if (!await wgConfExists(confId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const confPath = path.join(WG_PATH, `wg${confId}.conf`)
|
||||
const conf = await fs.readFile(confPath, 'utf-8')
|
||||
return enc.Hex.stringify(SHA256(conf));
|
||||
}
|
||||
|
||||
export async function writeConfigFile(wg: WgServer): Promise<void> {
|
||||
const CONFIG_PATH = path.join(WG_PATH, `wg${wg.confId}.conf`)
|
||||
await fs.writeFile(CONFIG_PATH, await genServerConf(wg), { mode: 0o600 })
|
||||
await WGServer.update(wg.id, { confHash: await getConfigHash(wg.confId) });
|
||||
}
|
||||
|
||||
export async function maxConfId(): Promise<number> {
|
||||
// get files in /etc/wireguard
|
||||
const files = await fs.readdir(WG_PATH)
|
||||
// filter files that start with wg and end with .conf
|
||||
const reg = new RegExp(/^wg(\d+)\.conf$/)
|
||||
const confs = files.filter((f) => reg.test(f))
|
||||
const ids = confs.map((f) => {
|
||||
const m = f.match(reg)
|
||||
if (m) {
|
||||
return parseInt(m[1])
|
||||
}
|
||||
return 0
|
||||
})
|
||||
return Math.max(0, ...ids)
|
||||
}
|
||||
|
||||
export async function getServers(): Promise<WgServer[]> {
|
||||
return (await client.lrange(WG_SEVER_PATH, 0, -1)).map((s) => JSON.parse(s))
|
||||
}
|
||||
|
||||
export async function findServerIndex(id: string): Promise<number | undefined> {
|
||||
let index = 0;
|
||||
const servers = await getServers()
|
||||
for (const s of servers) {
|
||||
if (s.id === id) {
|
||||
return index
|
||||
}
|
||||
index++
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function findServer(id: string | undefined, hash?: string): Promise<WgServer | undefined> {
|
||||
const servers = await getServers()
|
||||
return id ?
|
||||
servers.find((s) => s.id === id) :
|
||||
hash && isJson(hash) ? servers.find((s) => JSON.stringify(s) === hash) :
|
||||
undefined
|
||||
}
|
||||
|
||||
export async function makeWgIptables(s: WgServer): Promise<{ up: string, down: string }> {
|
||||
|
||||
const inet = await Network.defaultInterface()
|
||||
const inet_address = await Shell.exec(`hostname -i | awk '{print $1}'`)
|
||||
|
||||
const source = `${s.address}/24`
|
||||
const wg_inet = `wg${s.confId}`
|
||||
|
||||
if (s.type === 'direct') {
|
||||
const up = dynaJoin([
|
||||
`iptables -t nat -A POSTROUTING -s ${source} -o ${inet} -j MASQUERADE`,
|
||||
`iptables -A INPUT -p udp -m udp --dport ${s.listen} -j ACCEPT`,
|
||||
`iptables -A INPUT -p tcp -m tcp --dport ${s.listen} -j ACCEPT`,
|
||||
`iptables -A FORWARD -i ${wg_inet} -j ACCEPT`,
|
||||
`iptables -A FORWARD -o ${wg_inet} -j ACCEPT`,
|
||||
]).join('; ')
|
||||
return { up, down: up.replace(/ -A /g, ' -D ') }
|
||||
}
|
||||
|
||||
if (s.type === 'tor') {
|
||||
const up = dynaJoin([
|
||||
`iptables -A INPUT -m state --state ESTABLISHED -j ACCEPT`,
|
||||
`iptables -A INPUT -i ${wg_inet} -s ${source} -m state --state NEW -j ACCEPT`,
|
||||
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} --dport 53 -j DNAT --to-destination ${inet_address}:53530`,
|
||||
`iptables -t nat -A PREROUTING -i ${wg_inet} -p tcp -s ${source} -j DNAT --to-destination ${inet_address}:9040`,
|
||||
`iptables -t nat -A PREROUTING -i ${wg_inet} -p udp -s ${source} -j DNAT --to-destination ${inet_address}:9040`,
|
||||
`iptables -t nat -A OUTPUT -o lo -j RETURN`,
|
||||
`iptables -A OUTPUT -m conntrack --ctstate INVALID -j DROP`,
|
||||
`iptables -A OUTPUT -m state --state INVALID -j DROP`,
|
||||
`iptables -A OUTPUT ! -o lo ! -d 127.0.0.1 ! -s 127.0.0.1 -p tcp -m tcp --tcp-flags ACK,FIN ACK,FIN -j DROP`,
|
||||
]).join('; ')
|
||||
return { up, down: up.replace(/-A/g, '-D') }
|
||||
}
|
||||
|
||||
return { up: '', down: '' }
|
||||
}
|
||||
|
||||
export async function genServerConf(server: WgServer): Promise<string> {
|
||||
|
||||
const iptables = await makeWgIptables(server)
|
||||
server.postUp = iptables.up
|
||||
server.postDown = iptables.down
|
||||
|
||||
const lines = [
|
||||
'# Autogenerated by WireGuard UI (WireAdmin)',
|
||||
'[Interface]',
|
||||
`PrivateKey = ${server.privateKey}`,
|
||||
`Address = ${server.address}/24`,
|
||||
`ListenPort = ${server.listen}`,
|
||||
`${server.dns ? `DNS = ${server.dns}` : 'OMIT'}`,
|
||||
'',
|
||||
`${server.preUp ? `PreUp = ${server.preUp}` : 'OMIT'}`,
|
||||
`${server.postUp ? `PostUp = ${server.postUp}` : 'OMIT'}`,
|
||||
`${server.preDown ? `PreDown = ${server.preDown}` : 'OMIT'}`,
|
||||
`${server.postDown ? `PostDown = ${server.postDown}` : 'OMIT'}`,
|
||||
]
|
||||
server.peers.forEach((peer, index) => {
|
||||
lines.push('')
|
||||
lines.push(`## ${peer.name || `Peer #${index + 1}`}`)
|
||||
lines.push('[Peer]')
|
||||
lines.push(`PublicKey = ${peer.publicKey}`)
|
||||
lines.push(`${peer.preSharedKey ? `PresharedKey = ${peer.preSharedKey}` : 'OMIT'}`)
|
||||
lines.push(`AllowedIPs = ${peer.allowedIps}/32`)
|
||||
lines.push(`${peer.persistentKeepalive ? `PersistentKeepalive = ${peer.persistentKeepalive}` : 'OMIT'}`)
|
||||
})
|
||||
|
||||
return lines
|
||||
.filter((l) => l !== 'OMIT')
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextApiResponse } from "next";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export function zodErrorToResponse(res: NextApiResponse, z: ZodError) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({
|
||||
ok: false,
|
||||
message: 'Bad Request',
|
||||
details: zodErrorMessage(z)
|
||||
})
|
||||
}
|
||||
|
||||
export function zodEnumError(message: string) {
|
||||
return { message }
|
||||
}
|
||||
|
||||
export function zodErrorMessage(ze: ZodError): string[] {
|
||||
return ze.errors.map((e) => e.message)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { withAuth } from "next-auth/middleware";
|
||||
|
||||
// More on how NextAuth.js middleware works: https://next-auth.js.org/configuration/nextjs#middleware
|
||||
export default withAuth({
|
||||
callbacks: {
|
||||
authorized({ token }) {
|
||||
return !!token
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// See "Matching Paths" below to learn more
|
||||
// https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api/auth|api/healthcheck|_next/static|_next/image|login|logo.png|fonts|favicon.ico).*)',
|
||||
],
|
||||
};
|
||||
5
src/next-env.d.ts
vendored
5
src/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,18 +0,0 @@
|
||||
function publicENV(ex = {}) {
|
||||
Object.entries(process.env)
|
||||
.filter(([ key ]) => key.startsWith('NEXT_PUBLIC_'))
|
||||
.forEach(([ key, value ]) => ex[key] = value)
|
||||
return ex
|
||||
}
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: [],
|
||||
env: publicENV({}),
|
||||
images: {
|
||||
domains: [ 'img.shields.io' ]
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = nextConfig;
|
||||
3612
src/package-lock.json
generated
3612
src/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"packageManager": "npm@9.7.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"antd": "5.10.2",
|
||||
"clsx": "^2.0.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dotenv": "16.3.1",
|
||||
"ioredis": "5.3.2",
|
||||
"next": "13.5.6",
|
||||
"next-auth": "4.24.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"sharp": "^0.32.6",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "13.5.6",
|
||||
"@types/crypto-js": "^4.1.3",
|
||||
"@types/node": "20.8.9",
|
||||
"@types/react": "18.2.33",
|
||||
"@types/react-dom": "18.2.14",
|
||||
"autoprefixer": "10.4.16",
|
||||
"postcss": "8.4.31",
|
||||
"tailwindcss": "3.3.5",
|
||||
"tsconfig": "*",
|
||||
"typescript": "5.2.2"
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Default",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"inlineSources": false,
|
||||
"isolatedModules": true,
|
||||
"moduleResolution": "node",
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Next.js",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"allowJs": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"incremental": true,
|
||||
"jsx": "preserve",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"module": "esnext",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": false,
|
||||
"target": "es5",
|
||||
},
|
||||
"include": ["src", "next-env.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "React Library",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"module": "ESNext",
|
||||
"target": "es6"
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
import { Button, Card, List } from "antd";
|
||||
import BasePage from "@ui/pages/BasePage";
|
||||
import PageRouter from "@ui/pages/PageRouter";
|
||||
import React from "react";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import useSWR from "swr";
|
||||
import { APIResponse, Peer, WgServer } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { useRouter } from "next/router";
|
||||
import { MiddleEllipsis } from "@ui/MiddleEllipsis";
|
||||
import StatusBadge from "@ui/StatusBadge";
|
||||
import { SmartModalRef } from "@ui/modal/SmartModal";
|
||||
import CreateClientModal from "@ui/modal/CreateClientModal";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import QRCodeModal from "@ui/modal/QRCodeModal";
|
||||
import { getPeerConf } from "@lib/wireguard-utils";
|
||||
import EditableText from "@ui/EditableText";
|
||||
import { RLS_NAME_INPUT } from "@lib/form-rules";
|
||||
import { UPDATE_CLIENT } from "@lib/swr-fetch";
|
||||
import CopiableWrapper from "@ui/CopiableWrapper";
|
||||
|
||||
|
||||
export async function getServerSideProps(context: any) {
|
||||
return {
|
||||
props: {
|
||||
serverId: context.params.serverId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
serverId: string
|
||||
}
|
||||
|
||||
export default function ServerPage(props: PageProps) {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const createClientRef = React.useRef<SmartModalRef | null>(null)
|
||||
|
||||
const { data, error, isLoading, mutate: refresh } = useSWR(
|
||||
`/api/wireguard/${props.serverId}`,
|
||||
async (url: string) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
if (resp.status === 404) {
|
||||
router.replace('/').catch()
|
||||
return false
|
||||
}
|
||||
const data = await resp.json() as APIResponse<WgServer>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return data.result
|
||||
}
|
||||
)
|
||||
|
||||
const { isMutating: isChangingStatus, trigger: changeStatus } = useSWRMutation(
|
||||
`/api/wireguard/${props.serverId}`,
|
||||
async (url: string, { arg }: { arg: string }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: arg === 'remove' ? 'DELETE' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: arg === 'remove' ? undefined : JSON.stringify({ status: arg })
|
||||
})
|
||||
if (resp.status === 404) {
|
||||
router.replace('/').catch()
|
||||
return false
|
||||
}
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return true
|
||||
},
|
||||
{
|
||||
onSuccess: async () => await refresh(),
|
||||
onError: async () => await refresh(),
|
||||
}
|
||||
)
|
||||
|
||||
const lastChangeStatus = React.useRef<string | null>(null)
|
||||
|
||||
return (
|
||||
<BasePage>
|
||||
<CreateClientModal
|
||||
ref={createClientRef}
|
||||
serverId={props.serverId}
|
||||
refreshTrigger={() => refresh()}
|
||||
/>
|
||||
<PageRouter
|
||||
route={[
|
||||
{ title: data ? data.name.toString() : 'LOADING...' }
|
||||
]}
|
||||
/>
|
||||
{error || (!isLoading && !data) ? (
|
||||
<Card className={'flex items-center justify-center p-4'}>
|
||||
! ERROR !
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Card className={'flex items-center justify-center p-4'}>
|
||||
Loading...
|
||||
</Card>
|
||||
) : data && (
|
||||
<div className={'space-y-4'}>
|
||||
<Card className={'[&>.ant-card-body]:max-md:p1-2'}>
|
||||
<List>
|
||||
<Row label={'IP address'}>
|
||||
<pre> {data.address}/24 </pre>
|
||||
</Row>
|
||||
<Row label={'Listen Port'}>
|
||||
<pre> {data.listen} </pre>
|
||||
</Row>
|
||||
<Row label={'Status'}>
|
||||
<StatusBadge status={data.status} />
|
||||
</Row>
|
||||
<Row label={'Public Key'}>
|
||||
<CopiableWrapper content={data.publicKey}>
|
||||
<MiddleEllipsis
|
||||
content={data.publicKey}
|
||||
maxLength={16}
|
||||
/>
|
||||
</CopiableWrapper>
|
||||
</Row>
|
||||
</List>
|
||||
<div className={'flex flex-wrap items-center gap-2 mt-6'}>
|
||||
{data.status === 'up' ? (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'restart'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('restart')}
|
||||
> Restart </Button>
|
||||
<Button
|
||||
danger={true}
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'stop'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('stop')}
|
||||
> Stop </Button>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
type={'primary'}
|
||||
className={'max-md:col-span-12 bg-green-500'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'start'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => changeStatus('start')}
|
||||
> Start </Button>
|
||||
<Button
|
||||
danger={true}
|
||||
className={'max-md:col-span-12'}
|
||||
loading={isChangingStatus && lastChangeStatus.current === 'remove'}
|
||||
disabled={isChangingStatus}
|
||||
onClick={() => {
|
||||
changeStatus('remove').finally(() => {
|
||||
lastChangeStatus.current = null
|
||||
router.replace('/').catch()
|
||||
})
|
||||
}}
|
||||
> Remove </Button>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={'[&>.ant-card-body]:p-0'}
|
||||
title={(
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span> Clients </span>
|
||||
|
||||
{data && data.peers.length > 0 && (
|
||||
<div>
|
||||
<Button
|
||||
type={'primary'}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => createClientRef.current?.open()}
|
||||
>
|
||||
Add a client
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{data && data.peers.length > 0 ? (
|
||||
<List>
|
||||
{data.peers.map((s) => (
|
||||
<Client
|
||||
key={s.id}
|
||||
{...s}
|
||||
serverId={props.serverId}
|
||||
serverPublicKey={data?.publicKey}
|
||||
dns={data?.dns}
|
||||
listenPort={data?.listen}
|
||||
refreshTrigger={() => refresh()}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<div className={'flex flex-col items-center justify-center gap-y-4 py-8'}>
|
||||
<p className={'text-gray-400 text-md'}>
|
||||
There are no clients yet!
|
||||
</p>
|
||||
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createClientRef.current?.open()}>
|
||||
Add a client
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
interface ClientProps extends Peer, Pick<WgServer, 'dns'> {
|
||||
serverId: string
|
||||
serverPublicKey: string
|
||||
listenPort: number
|
||||
refreshTrigger: () => void
|
||||
}
|
||||
|
||||
function Client(props: ClientProps) {
|
||||
|
||||
const qrcodeRef = React.useRef<SmartModalRef | null>(null)
|
||||
|
||||
const [ conf, setConf ] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
getPeerConf({
|
||||
...props,
|
||||
serverPublicKey: props.serverPublicKey,
|
||||
port: props.listenPort,
|
||||
dns: props.dns,
|
||||
})
|
||||
.then((s) => setConf(s))
|
||||
}, [ props ])
|
||||
|
||||
const RefreshOptions = {
|
||||
onSuccess: () => props.refreshTrigger(),
|
||||
onError: () => props.refreshTrigger()
|
||||
}
|
||||
|
||||
const { isMutating: removingClient, trigger: removeClient } = useSWRMutation(
|
||||
`/api/wireguard/${props.serverId}/${props.id}`,
|
||||
async (url: string,) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return true
|
||||
},
|
||||
RefreshOptions
|
||||
)
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
`/api/wireguard/${props.serverId}/${props.id}`,
|
||||
UPDATE_CLIENT,
|
||||
RefreshOptions
|
||||
)
|
||||
|
||||
return (
|
||||
<List.Item key={props.id} className={'flex items-center justify-between p-4'}>
|
||||
<QRCodeModal ref={qrcodeRef} content={conf?.trim() || 'null'} />
|
||||
<div className={'w-full flex flex-row items-center gap-x-2'}>
|
||||
|
||||
<div
|
||||
className={'w-12 aspect-square flex items-center justify-center mr-4 rounded-full bg-gray-200 max-md:hidden'}>
|
||||
<i className={'fas fa-user text-gray-400 text-lg'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col items-start justify-between'}>
|
||||
<EditableText
|
||||
disabled={isMutating}
|
||||
rules={RLS_NAME_INPUT}
|
||||
rootClassName={'font-medium col-span-4'}
|
||||
inputClassName={'w-20'}
|
||||
content={props.name}
|
||||
onChange={(v) => trigger({ name: v })}
|
||||
/>
|
||||
<CopiableWrapper content={props.allowedIps} className={'text-sm'} showInHover={true}>
|
||||
<span className={'font-mono text-gray-400 text-xs'}> {props.allowedIps} </span>
|
||||
</CopiableWrapper>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className={'flex items-center justify-center gap-x-3'}>
|
||||
{/* QRCode */}
|
||||
<ClientBaseButton disabled={removingClient} onClick={() => {
|
||||
qrcodeRef.current?.open()
|
||||
}}>
|
||||
<i className={'fal text-neutral-700 group-hover:text-primary fa-qrcode'} />
|
||||
</ClientBaseButton>
|
||||
|
||||
{/* Download */}
|
||||
<ClientBaseButton disabled={removingClient} onClick={() => {
|
||||
if (!conf) {
|
||||
console.error('conf is null')
|
||||
return
|
||||
}
|
||||
console.log('conf', conf)
|
||||
// create a blob
|
||||
const blob = new Blob([ conf ], { type: 'text/plain' })
|
||||
// create a link
|
||||
const link = document.createElement('a')
|
||||
link.href = window.URL.createObjectURL(blob)
|
||||
link.download = `${props.name}.conf`
|
||||
// click the link
|
||||
link.click()
|
||||
// remove the link
|
||||
link.remove()
|
||||
}}>
|
||||
<i className={'fal text-neutral-700 group-hover:text-primary fa-download'} />
|
||||
</ClientBaseButton>
|
||||
|
||||
{/* Remove */}
|
||||
<ClientBaseButton loading={removingClient} onClick={() => removeClient()}>
|
||||
<i className={'fal text-neutral-700 group-hover:text-primary text-lg fa-trash-can'} />
|
||||
</ClientBaseButton>
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientBaseButton(props: {
|
||||
onClick: () => void
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'group flex items-center justify-center w-10 aspect-square rounded-md',
|
||||
'bg-gray-200/80 hover:bg-gray-100/50',
|
||||
'border border-transparent hover:border-primary',
|
||||
'transition-colors duration-200 ease-in-out',
|
||||
'cursor-pointer',
|
||||
props.disabled && 'opacity-50 cursor-not-allowed',
|
||||
props.loading && 'animate-pulse'
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<List.Item className={'flex flex-wrap items-center gap-2 leading-none relative overflow-ellipsis'}>
|
||||
<div className={'flex items-center text-gray-400 text-sm col-span-12 md:col-span-3'}>
|
||||
{props.label}
|
||||
</div>
|
||||
<div className={'flex items-center gap-x-2 col-span-12 md:col-span-9'}>
|
||||
{props.children}
|
||||
</div>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { ThemeConfig } from "antd";
|
||||
import { ConfigProvider } from "antd";
|
||||
|
||||
const theme: ThemeConfig = {
|
||||
token: {
|
||||
fontSize: 16,
|
||||
colorPrimary: '#991b1b',
|
||||
},
|
||||
};
|
||||
|
||||
export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<ConfigProvider theme={theme}>
|
||||
<Component {...pageProps} />
|
||||
</ConfigProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<link rel={'stylesheet'} href={'/fonts/fontawesome/index.css'} />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import NextAuth from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import "dotenv/config";
|
||||
|
||||
export default NextAuth({
|
||||
providers: [
|
||||
// Credentials-based authentication providers
|
||||
// https://next-auth.js.org/configuration/providers#credentials-based-authentication-providers
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(_, request) {
|
||||
const { password } = request.query || {}
|
||||
const { HASHED_PASSWORD } = process.env
|
||||
if (
|
||||
// Skip if no password is set
|
||||
!HASHED_PASSWORD ||
|
||||
// Accept if password was matching
|
||||
password && Buffer.from(password).toString('hex').toLowerCase() === HASHED_PASSWORD.toLowerCase()
|
||||
) {
|
||||
return { id: crypto.randomUUID() }
|
||||
}
|
||||
// Reject the rest
|
||||
return null
|
||||
}
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: '/login'
|
||||
},
|
||||
session: {
|
||||
// Seconds - How long until an idle session expires and is no longer valid.
|
||||
maxAge: 24 * 60 * 60, // 24 hours
|
||||
// Seconds - Throttle how frequently to write to database to extend a session.
|
||||
// Use it to limit write operations. Set to 0 to always update the database.
|
||||
// Note: This option is ignored if using JSON Web Tokens
|
||||
updateAge: 60 * 60, // 1 hour
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { getConfigHash, getServers, WGServer } from "@lib/wireguard";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const servers = await getServers()
|
||||
|
||||
for (const s of servers) {
|
||||
|
||||
const HASH = await getConfigHash(s.confId);
|
||||
if (s.confId && HASH && s.confHash === HASH) {
|
||||
// Skip, due to no changes on the config
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start server
|
||||
if (s.status === 'up') {
|
||||
await WGServer.start(s.id);
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.end('OK')
|
||||
|
||||
})
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import Shell from "@lib/shell";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
let { WG_HOST } = process.env
|
||||
|
||||
// if the host is not set, then we are using the server's public IP
|
||||
if (!WG_HOST) {
|
||||
const resp = await Shell.exec('curl -s ifconfig.me', true)
|
||||
WG_HOST = resp.trim()
|
||||
}
|
||||
|
||||
// check if WG_HOST is still not set
|
||||
if (!WG_HOST) {
|
||||
console.error('WG_HOST is not set')
|
||||
return res
|
||||
.status(500)
|
||||
.setHeader('Content-Type', 'text/plain')
|
||||
.end('NOT_SET')
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.setHeader('Content-Type', 'text/plain')
|
||||
.end(WG_HOST)
|
||||
|
||||
})
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { client } from "@/lib/redis";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
// create a list for storing the last 10 pings
|
||||
await client.lpush("pings", Date.now().toString())
|
||||
await client.ltrim("pings", 0, 9)
|
||||
|
||||
const pings = await client.lrange("pings", 0, -1)
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: 'Pong!', pings })
|
||||
|
||||
})
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
import { z } from "zod";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { ServerId } from "@lib/schemas/WireGuard";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
const conf = await WGServer.generatePeerConfig(server.id, clientId)
|
||||
if (!conf) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, message: 'Server Internal Error' })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.end(conf)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId,
|
||||
clientId: z.string().uuid()
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
import { z } from "zod";
|
||||
import { findServer } from "@lib/wireguard";
|
||||
import { AddressSchema, ServerId } from "@lib/schemas/WireGuard";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId,
|
||||
clientId: AddressSchema
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { ClientId, NameSchema, ServerId } from "@lib/schemas/WireGuard";
|
||||
import { WgServer } from "@lib/typings";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { serverId, clientId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
const peer = server.peers.find((p) => p.id === clientId)
|
||||
if (!peer) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, result: peer })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
return await update(server, peer, req, res)
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
return await remove(server, peer, req, res)
|
||||
}
|
||||
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId,
|
||||
clientId: ClientId
|
||||
})
|
||||
|
||||
type Peer = WgServer['peers'][0]
|
||||
|
||||
async function update(server: WgServer, peer: Peer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = PutRequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { name } = req.body as z.infer<typeof PutRequestSchema>
|
||||
|
||||
if (name) {
|
||||
await WGServer.updatePeer(server.id, peer.publicKey, { name })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const PutRequestSchema = z.object({
|
||||
name: NameSchema.optional()
|
||||
})
|
||||
|
||||
async function remove(server: WgServer, peer: Peer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
await WGServer.removePeer(server.id, peer.publicKey)
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer, generateWgKey, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { NameSchema, ServerId } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const parsed = RequestQuerySchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const bodyParsed = RequestBodySchema.safeParse(req.body)
|
||||
if (!bodyParsed.success) {
|
||||
return zodErrorToResponse(res, bodyParsed.error)
|
||||
}
|
||||
|
||||
const { name } = req.body as z.infer<typeof RequestBodySchema>
|
||||
const { serverId } = req.query as z.infer<typeof RequestQuerySchema>
|
||||
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
const freeAddress = await WGServer.getFreePeerIp(server.id)
|
||||
if (!freeAddress) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'No free addresses' })
|
||||
}
|
||||
|
||||
const peerKeys = await generateWgKey()
|
||||
|
||||
const addedPeer = await WGServer.addPeer(server.id, {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
allowedIps: freeAddress,
|
||||
publicKey: peerKeys.publicKey,
|
||||
privateKey: peerKeys.privateKey,
|
||||
preSharedKey: peerKeys.preSharedKey,
|
||||
persistentKeepalive: 0,
|
||||
})
|
||||
if (!addedPeer) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Failed to add peer' })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestQuerySchema = z.object({
|
||||
serverId: ServerId,
|
||||
})
|
||||
|
||||
const RequestBodySchema = z.object({
|
||||
name: NameSchema,
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { NameSchema, ServerId } from "@lib/schemas/WireGuard";
|
||||
import { WgServer } from "@lib/typings";
|
||||
import { zodEnumError, zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.query)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { serverId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, result: server })
|
||||
}
|
||||
|
||||
if (req.method === 'PUT') {
|
||||
return await update(server, req, res)
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
return await remove(server, req, res)
|
||||
}
|
||||
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId
|
||||
})
|
||||
|
||||
|
||||
async function update(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
const parsed = PutRequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { status, name } = req.body as z.infer<typeof PutRequestSchema>
|
||||
|
||||
switch (status) {
|
||||
case 'start':
|
||||
await WGServer.start(server.id)
|
||||
break;
|
||||
case 'stop':
|
||||
await WGServer.stop(server.id)
|
||||
break;
|
||||
case 'restart':
|
||||
await WGServer.stop(server.id)
|
||||
await WGServer.start(server.id)
|
||||
break;
|
||||
}
|
||||
|
||||
if (name) {
|
||||
await WGServer.update(server.id, { name })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const PutRequestSchema = z.object({
|
||||
name: NameSchema.optional(),
|
||||
status: z
|
||||
.enum(
|
||||
[ 'start', 'stop', 'restart' ],
|
||||
{ errorMap: () => zodEnumError('Invalid status') }
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
async function remove(server: WgServer, req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
await WGServer.remove(server.id)
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { findServer, WGServer } from "@lib/wireguard";
|
||||
import { z } from "zod";
|
||||
import { ServerId } from "@lib/schemas/WireGuard";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const { serverId } = req.query as z.infer<typeof RequestSchema>
|
||||
const server = await findServer(serverId)
|
||||
|
||||
if (!server) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ ok: false, message: 'Not Found' })
|
||||
}
|
||||
|
||||
const updated = await WGServer.stop(serverId)
|
||||
if (!updated) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ ok: false, details: 'Server Internal Error' })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
serverId: ServerId
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { z } from "zod";
|
||||
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
|
||||
import { generateWgServer } from "@lib/wireguard";
|
||||
import { zodErrorToResponse } from "@lib/zod";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
const parsed = RequestSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return zodErrorToResponse(res, parsed.error)
|
||||
}
|
||||
|
||||
const { name, address, type, port, dns = null, mtu = 1420 } = req.body
|
||||
|
||||
const serverId = await generateWgServer({
|
||||
name,
|
||||
address,
|
||||
port,
|
||||
type,
|
||||
mtu,
|
||||
dns
|
||||
})
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true, result: serverId })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
const RequestSchema = z.object({
|
||||
name: NameSchema,
|
||||
address: AddressSchema,
|
||||
port: PortSchema,
|
||||
type: TypeSchema,
|
||||
dns: DnsSchema,
|
||||
mtu: MtuSchema
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { getServers } from "@lib/wireguard";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({
|
||||
ok: true,
|
||||
result: (await getServers()).map((s) => s)
|
||||
})
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import safeServe from "@lib/safe-serve";
|
||||
import { client, WG_SEVER_PATH } from "@lib/redis";
|
||||
import fs from "fs";
|
||||
import Shell from "@lib/shell";
|
||||
import path from "path";
|
||||
import { WG_PATH } from "@lib/constants";
|
||||
|
||||
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return safeServe(res, async () => {
|
||||
|
||||
if (req.method !== 'GET') {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, details: 'Method not allowed' })
|
||||
}
|
||||
|
||||
// delete all servers
|
||||
await client.del(WG_SEVER_PATH)
|
||||
|
||||
// delete every file in /etc/wireguard
|
||||
fs.readdirSync(WG_PATH).forEach((file) => {
|
||||
const reg = new RegExp(/^wg(\d+)\.conf$/)
|
||||
const m = file.match(reg)
|
||||
if (m) {
|
||||
const confId = parseInt(m[1])
|
||||
Shell.exec(`ip link set down dev wg${confId}`).catch()
|
||||
fs.unlinkSync(path.join(WG_PATH, file))
|
||||
}
|
||||
})
|
||||
|
||||
return res
|
||||
.status(200)
|
||||
.json({ ok: true })
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button, Card, List } from "antd";
|
||||
import BasePage from "@ui/pages/BasePage";
|
||||
import { APIResponse, WgServer } from "@lib/typings";
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import Link from "next/link";
|
||||
import PageRouter from "@ui/pages/PageRouter";
|
||||
import useSWR from "swr";
|
||||
import { SmartModalRef } from "@ui/modal/SmartModal";
|
||||
import CreateServerModal from "@ui/modal/CreateServerModal";
|
||||
import StatusBadge from "@ui/StatusBadge";
|
||||
import EditableText from "@ui/EditableText";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { UPDATE_SERVER } from "@lib/swr-fetch";
|
||||
import { RLS_NAME_INPUT } from "@lib/form-rules";
|
||||
import CopiableWrapper from "@ui/CopiableWrapper";
|
||||
import ServerIcon from "@ui/icons/ServerIcon";
|
||||
|
||||
export default function Home() {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
'/api/wireguard/listServers',
|
||||
async (url: string) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
const data = await resp.json() as APIResponse<WgServer[]>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return data.result
|
||||
}
|
||||
)
|
||||
const createServerRef = React.useRef<SmartModalRef | null>(null)
|
||||
return (
|
||||
<BasePage>
|
||||
<PageRouter className={'flex items-center justify-between'}>
|
||||
<h2 className={'font-bold text-xl'}> Hello there 👋 </h2>
|
||||
{data && data.length > 0 && (
|
||||
<Button type={'default'} icon={<PlusOutlined />} onClick={() => createServerRef.current?.open()}>
|
||||
New server
|
||||
</Button>
|
||||
)}
|
||||
</PageRouter>
|
||||
<CreateServerModal ref={createServerRef} refreshTrigger={() => mutate()} />
|
||||
<div className={'space-y-4'}>
|
||||
{error ? (
|
||||
<Card className={'flex items-center justify-center p-4'}>
|
||||
! ERROR !
|
||||
</Card>
|
||||
) : isLoading ? (
|
||||
<Card className={'flex items-center justify-center p-4'}>
|
||||
Loading...
|
||||
</Card>
|
||||
) : Array.isArray(data) && data.length > 0 ? (
|
||||
<Card
|
||||
className={'[&>.ant-card-body]:p-0'}
|
||||
title={<span> Servers </span>}
|
||||
>
|
||||
<List>
|
||||
{data.map((s) => (
|
||||
<ServerListItem
|
||||
{...s}
|
||||
key={s.id}
|
||||
refreshTrigger={() => mutate()}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className={'flex flex-col items-center justify-center gap-y-4 py-8'}>
|
||||
<p className={'text-gray-400 text-md'}>
|
||||
There are no servers yet!
|
||||
</p>
|
||||
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createServerRef.current?.open()}>
|
||||
Add a server
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerListItemProps extends WgServer {
|
||||
refreshTrigger: () => void
|
||||
}
|
||||
|
||||
function ServerListItem(props: ServerListItemProps) {
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
`/api/wireguard/${props.id}`,
|
||||
UPDATE_SERVER,
|
||||
{
|
||||
onSuccess: () => props.refreshTrigger(),
|
||||
onError: () => props.refreshTrigger(),
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<List.Item className={'flex items-center justify-between p-4'}>
|
||||
<div className={'w-full grid grid-cols-8 md:grid-cols-12 items-center gap-x-1'}>
|
||||
<ServerIcon type={props.type} className={'max-md:hidden md:col-span-1'} />
|
||||
<div className={'flex flex-col justify-between col-span-4'}>
|
||||
<EditableText
|
||||
disabled={isMutating}
|
||||
rules={RLS_NAME_INPUT}
|
||||
rootClassName={'font-medium'}
|
||||
inputClassName={'w-full max-w-[120px]'}
|
||||
content={props.name}
|
||||
onChange={(v) => trigger({ name: v })}
|
||||
/>
|
||||
<CopiableWrapper
|
||||
content={`${props.address}:${props.listen}`}
|
||||
className={'text-sm'}
|
||||
rootClassName={'mt-0.5'}
|
||||
showInHover={true}
|
||||
>
|
||||
<span className={'font-mono text-gray-400 text-xs'}> {props.address}:{props.listen} </span>
|
||||
</CopiableWrapper>
|
||||
</div>
|
||||
<div className={'col-span-4 justify-end'}>
|
||||
<StatusBadge status={props.status} />
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/${props.id}`}>
|
||||
<Button type={'primary'}>
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
</List.Item>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import PageFooter from "@ui/pages/PageFooter";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { Button, Form, Input } from "antd";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export default function LoginPage() {
|
||||
|
||||
const router = useRouter()
|
||||
const [ form ] = Form.useForm()
|
||||
|
||||
async function handleFinish({ password }: { password: string | undefined }) {
|
||||
if (!password) {
|
||||
return form.resetFields()
|
||||
}
|
||||
|
||||
await signIn(
|
||||
'credentials',
|
||||
{ redirect: false },
|
||||
{ password }
|
||||
)
|
||||
|
||||
await router.push('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'w-full min-h-screen flex justify-center px-2 md:px-6 py-2'}>
|
||||
<div className={'w-full mx-auto max-w-3xl flex flex-col items-center gap-y-3.5'}>
|
||||
<header className={'flex items-center gap-x-2 text-3xl font-medium py-4'}>
|
||||
<Image
|
||||
src={'/logo.png'}
|
||||
alt={'WireAdmin'}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<h1> WireAdmin </h1>
|
||||
</header>
|
||||
<main className={'py-4'}>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFinish}
|
||||
rootClassName={'bg-white rounded-lg shadow-sm'}
|
||||
className={'p-4 space-y-8'}
|
||||
>
|
||||
<div className={'flex items-center justify-center'}>
|
||||
<div className={'w-16 aspect-square flex items-center justify-center rounded-full bg-gray-200'}>
|
||||
<i className={'fas fa-user text-primary text-2xl'} />
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name={'password'}>
|
||||
<Input placeholder={'Password...'} />
|
||||
</Form.Item>
|
||||
<Button block={true} onClick={form.submit}> Sign In </Button>
|
||||
</Form>
|
||||
</main>
|
||||
<PageFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
9
src/public/fonts/fontawesome/index.css
vendored
9
src/public/fonts/fontawesome/index.css
vendored
File diff suppressed because one or more lines are too long
@@ -1,99 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="KVk0SCZ2CVA7excpJHEWdT0CIQYhLxMaq-ZxeGh6ZLCnV6Z1zEcRXaY-">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="icon" href="/images/favicon/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/images/favicon/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest/app.json">
|
||||
|
||||
<meta name="theme-color" content="#528DD7">
|
||||
|
||||
|
||||
<title>Font Awesome</title>
|
||||
<meta id="meta-application-name" name="application-name" content="Font Awesome" />
|
||||
<meta id="meta-description" name="description" content="The world’s most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
|
||||
<meta id="meta-keywords" name="keywords" content="icons, vector icons, svg icons, free icons, icon font, webfont, desktop icons, svg, font awesome, font awesome free, font awesome pro" />
|
||||
|
||||
<!-- Schema.org markup for Google+ -->
|
||||
<meta id="meta-item-name" itemprop="name" content="Font Awesome" />
|
||||
<meta id="meta-item-description" itemprop="description" content="The world’s most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
|
||||
<meta id="meta-item-image" itemprop="image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
|
||||
|
||||
<!-- Twitter Card data -->
|
||||
<meta id="twt-card" name="twitter:card" content="summary" />
|
||||
<meta id="twt-site" name="twitter:site" content="@fontawesome" />
|
||||
<meta id="twt-title" name="twitter:title" content="Font Awesome" />
|
||||
<meta id="twt-description" name="twitter:description" content="The world’s most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
|
||||
<meta id="twt-creator" name="twitter:creator" content="@fontawesome" />
|
||||
<meta id="twt-image" name="twitter:image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
|
||||
|
||||
<!-- Open Graph data -->
|
||||
<meta id="og-title" property="og:title" content="Font Awesome" />
|
||||
<meta id="og-type" property="og:type" content="website" />
|
||||
<meta id="og-url" property="og:url" content="https://fontawesome.com" />
|
||||
<meta id="og-image" property="og:image" content="https://img.fortawesome.com/349cfdf6/fontawesome-open-graph.png" />
|
||||
<meta id="og-description" property="og:description" content="The world’s most popular and easiest to use icon set just got an upgrade. More icons. More styles. More Options." />
|
||||
|
||||
|
||||
<link
|
||||
rel="stylesheet"
|
||||
data-purpose="Layout StyleSheet"
|
||||
title="Default"
|
||||
|
||||
href="/css/app-eebc29a5e707f565ed420d151441665a.css?vsn=d"
|
||||
>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
data-purpose="Layout StyleSheet"
|
||||
title="Web Awesome"
|
||||
disabled
|
||||
href="/css/app-wa-b464d3417b7a8fcc0b8bfe9cf7d4bc92.css?vsn=d"
|
||||
>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
disabled
|
||||
href="https://pro.fontawesome.com/releases/v6.0.0-beta3/css/all.css"
|
||||
>
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-30136587-4"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'UA-30136587-4', { cookie_flags: 'max-age=7200;secure;samesite=none' });
|
||||
</script>
|
||||
|
||||
|
||||
<link rel="preload" href="/js/settings-445a010757cbad60e82500f2dc6c83dc.js?vsn=d" as="script">
|
||||
<link rel="preload" href="/js/app-8be57055a5918601da69ea599f03aee0.js?vsn=d" as="script">
|
||||
|
||||
<script defer src="/js/settings-445a010757cbad60e82500f2dc6c83dc.js?vsn=d"></script>
|
||||
<script defer src="/js/app-8be57055a5918601da69ea599f03aee0.js?vsn=d"></script>
|
||||
<script type="text/javascript">!function(e,t,n){function a(){var e=t.getElementsByTagName("script")[0],n=t.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://beacon-v2.helpscout.net",e.parentNode.insertBefore(n,e)}if(e.Beacon=n=function(t,n,a){e.Beacon.readyQueue.push({method:t,options:n,data:a})},n.readyQueue=[],"complete"===t.readyState)return a();e.attachEvent?e.attachEvent("onload",a):e.addEventListener("load",a,!1)}(window,document,window.Beacon||function(){});</script>
|
||||
<script type="text/javascript">
|
||||
window.Beacon('init', '8b4d2c82-4277-4380-9212-e4e7f03c1ea4');
|
||||
window.Beacon('config', {display: {style: 'manual'}})
|
||||
</script>
|
||||
</head>
|
||||
<body class="min-vh-100 bg-gray0 gray7 ma0 overflow-x-hidden">
|
||||
|
||||
<div id="vue-container">
|
||||
</div>
|
||||
<div id="modal"></div>
|
||||
<div id="shade"></div>
|
||||
<script>
|
||||
window.__inline_data__ = [{"data":[{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Early Access","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":1},"id":"1","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Core Font Awesome 4 Icons Redesigned","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":2},"id":"2","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Icons in SVG Format","percentage-complete":100,"updated-at":"2017-12-06T15:32:04","view-order":3},"id":"3","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2018-03-29T14:07:48","name":"Revamped Icon Font Framework","percentage-complete":100,"updated-at":"2018-07-24T14:40:06","view-order":4},"id":"14","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"SVG Icon Framework","percentage-complete":100,"updated-at":"2017-12-06T15:32:16","view-order":5},"id":"4","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Regular and Light Icon Styles","percentage-complete":100,"updated-at":"2017-08-02T21:36:24","view-order":6},"id":"5","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Icon Font Ligatures","percentage-complete":100,"updated-at":"2018-07-24T14:43:16","view-order":7},"id":"7","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Dedicated CDN","percentage-complete":100,"updated-at":"2018-07-24T14:43:27","view-order":8},"id":"10","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Duotone Colored Icons","percentage-complete":100,"updated-at":"2018-07-24T14:45:51","view-order":9},"id":"6","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Desktop Icon Subsetter","percentage-complete":100,"updated-at":"2018-07-24T14:45:29","view-order":10},"id":"8","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Community Voting","percentage-complete":75,"updated-at":"2018-07-24T14:44:56","view-order":11},"id":"11","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2018-03-29T14:07:48","name":"iOS and Android Support","percentage-complete":0,"updated-at":"2018-07-24T14:46:05","view-order":12},"id":"15","links":{"self":"/api/product-updates"},"type":"product-update"},{"attributes":{"inserted-at":"2017-08-02T21:36:24","name":"Desktop Design Plugins","percentage-complete":0,"updated-at":"2018-07-24T14:46:20","view-order":13},"id":"9","links":{"self":"/api/product-updates"},"type":"product-update"}],"jsonapi":{"version":"1.0"}}]
|
||||
</script>
|
||||
|
||||
<script src="https://use.fortawesome.com/349cfdf6.js"></script>
|
||||
|
||||
<script defer src="https://m.servedby-buysellads.com/monetization.js"></script>
|
||||
<script defer src="https://js.stripe.com/v3/"></script>
|
||||
<script defer src="https://www.google.com/recaptcha/api.js?render=6Lfwy8YZAAAAAOymsOdsZ7xDAG-TFKW_fij1Wnjg"></script>
|
||||
<script defer src="https://embed.typeform.com/embed.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient x1="50%" y1="100%" x2="50%" y2="0%" id="linearGradient-1">
|
||||
<stop stop-color="#420C5D" offset="0%"></stop>
|
||||
<stop stop-color="#951AD1" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g transform="translate(-58.12 -303.3)" fill="url(#linearGradient-1)" fill-rule="evenodd">
|
||||
<path d="m77.15 303.3c-1.608 1.868-0.09027 2.972-0.9891 4.84 1.514-2.129 5.034-2.862 7.328-3.643-3.051 2.72-5.457 6.326-8.489 9.009l-1.975-0.8374c-0.4647-4.514-1.736-4.705 4.125-9.369z"
|
||||
fill-rule="evenodd" />
|
||||
<path d="m74.04 313.1 2.932 0.9454c-0.615 2.034 0.3559 2.791 0.9472 3.123 1.324 0.7332 2.602 1.49 3.619 2.412 1.916 1.75 3.004 4.21 3.004 6.812 0 2.578-1.183 5.061-3.169 6.717-1.868 1.561-4.446 2.223-6.953 2.223-1.561 0-2.956-0.0708-4.47-0.5677-3.453-1.159-6.031-4.115-6.244-7.663-0.1893-2.767 0.4257-4.872 2.578-7.072 1.111-1.159 2.563-2.749 4.1-3.813 0.757-0.5204 1.119-1.191-0.4183-3.958l1.28-1.076 2.795 1.918-2.352-0.3007c0.1656 0.2366 1.189 0.7706 1.284 1.078 0.2128 0.8751-0.1911 1.771-0.3804 2.149-0.9696 1.75-1.86 2.275-3.066 3.268-2.129 1.75-4.27 2.836-4.01 7.637 0.1183 2.365 1.433 5.295 4.2 6.643 1.561 0.757 2.859 1.189 4.68 1.284 1.632 0.071 4.754-0.8988 6.457-2.318 1.821-1.514 2.838-3.808 2.838-6.149 0-2.365-0.9461-4.612-2.72-6.197-1.017-0.9223-2.696-2.034-3.737-2.625-1.041-0.5912-2.782-2.06-2.356-3.645z" />
|
||||
<path d="m73.41 316.6c-0.186 1.088-0.4177 3.117-0.8909 3.917-0.3293 0.5488-0.4126 0.8101-0.7846 1.094-1.09 1.535-1.45 1.761-2.132 4.552-0.1447 0.5914-0.3832 1.516-0.2591 2.107 0.372 1.703 0.6612 2.874 1.316 4.103 0 0 0.1271 0.1217 0.1271 0.169 0.6821 0.9225 0.6264 1.05 2.665 2.246l-0.06204 0.3313c-1.55-0.4729-2.604-0.9591-3.41-2.024 0-0.0236-0.1513-0.1558-0.1513-0.1558-0.868-1.135-1.753-2.788-2.021-4.546-0.1447-0.7097-0.0769-1.341 0.08833-2.075 0.7026-2.885 1.415-4.093 2.744-5.543 0.3514-0.2601 0.6704-0.6741 1.001-1.092 0.4859-0.6764 1.462-2.841 1.814-4.189z" />
|
||||
<path d="m74.09 318.6c0.0237 1.04 0.0078 3.036 0.3389 3.796 0.0945 0.2599 0.3274 1.414 0.9422 2.794 0.4258 0.96 0.5418 1.933 0.6128 2.193 0.2838 1.14-0.4002 3.086-0.8734 4.906-0.2364 0.98-0.6051 1.773-1.371 2.412l0.2796 0.3593c0.5204-0.02 1.954-1.096 2.403-2.416 0.757-2.24 1.328-3.317 0.9729-5.797-0.0473-0.2402-0.2094-1.134-0.6588-2.014-0.6622-1.34-1.474-2.614-1.592-2.874-0.213-0.4198-1.007-2.119-1.054-3.359z" />
|
||||
<path d="m74.88 313.9 0.9727 0.4962c-0.09145 0.6403 0.04572 2.059 0.686 2.424 2.836 1.761 5.512 3.683 6.565 5.604 3.751 6.771-2.63 13.04-8.143 12.44 2.996-2.219 4.428-6.583 3.307-11.55-0.4574-1.944-1.729-3.893-2.987-5.883-0.545-0.9768-0.3547-2.188-0.4006-3.538z"
|
||||
fill-rule="evenodd" />
|
||||
<rect x="73.07" y="312.8" width="1" height="22" />
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="36px" height="36px" viewBox="0 0 36 36" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 62 (91390) - https://sketch.com -->
|
||||
<title>vps</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="vps" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<path d="M33,26 L33,31 C33,32.6568542 31.6568542,34 30,34 L6,34 C4.34314575,34 3,32.6568542 3,31 L3,26 L33,26 Z M30,29 L6,29 L6,31 L30,31 L30,29 Z M33,15 L33,23 L3,23 L3,15 L33,15 Z M30,18 L6,18 L6,20 L30,20 L30,18 Z M30,4 C31.6568542,4 33,5.34314575 33,7 L33,12 L3,12 L3,7 C3,5.34314575 4.34314575,4 6,4 L30,4 Z M30,7 L6,7 L6,9 L30,9 L30,7 Z" fill="#991b1b"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 779 B |
@@ -1,62 +0,0 @@
|
||||
@tailwind base;
|
||||
|
||||
@layer base {
|
||||
|
||||
button:not(:not(.ant-btn-primary)),
|
||||
[type='button']:not(:not(.ant-btn-primary)),
|
||||
[type='reset']:not(:not(.ant-btn-primary)),
|
||||
[type='submit']:not(:not(.ant-btn-primary))
|
||||
{
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
|
||||
h1 {
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl font-semibold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-xl font-semibold;
|
||||
}
|
||||
|
||||
h5 {
|
||||
@apply text-lg font-medium;
|
||||
}
|
||||
|
||||
h6 {
|
||||
@apply text-base font-medium;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 249, 250, 251;
|
||||
--primary-color: theme('colors.red.800');
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background-color: rgb(var(--background-rgb));
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
important: true,
|
||||
content: [
|
||||
'./ui/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
'xs': '375px',
|
||||
'2xl': '1440px',
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#88161a'
|
||||
},
|
||||
transparent: 'transparent',
|
||||
},
|
||||
fontSize: {
|
||||
'3xs': '.5rem',
|
||||
'2xs': '.625rem',
|
||||
'sm/2': '.8125rem',
|
||||
'md': '.9375rem',
|
||||
},
|
||||
fontWeight: {
|
||||
'thin': '100',
|
||||
'light': '300',
|
||||
'normal': '400',
|
||||
'medium': '500',
|
||||
'semibold': '600',
|
||||
'bold': '700',
|
||||
'extrabold': '800',
|
||||
'black': '900',
|
||||
},
|
||||
zIndex: {
|
||||
'-100': '100',
|
||||
'-1000': '1000',
|
||||
'-10000': '10000',
|
||||
'100': '100',
|
||||
'1000': '1000',
|
||||
'10000': '10000',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@lib/*": ["./lib/*"],
|
||||
"@ui/*": ["./ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import { ReactHTMLProps } from "@lib/typings";
|
||||
import { message } from "antd";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export interface CopiableWrapperProps extends Omit<ReactHTMLProps<HTMLSpanElement>, 'content'> {
|
||||
rootClassName?: string
|
||||
content: string | number
|
||||
showInHover?: boolean
|
||||
}
|
||||
|
||||
export default function CopiableWrapper(props: CopiableWrapperProps) {
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
rootClassName,
|
||||
className,
|
||||
showInHover = false,
|
||||
...rest
|
||||
} = props
|
||||
const [ messageApi, contextHolder ] = message.useMessage()
|
||||
return (
|
||||
<div className={twMerge('group flex items-center', rootClassName)}>
|
||||
{contextHolder}
|
||||
{children}
|
||||
<i {...rest}
|
||||
className={twMerge(
|
||||
'ml-2 mb-0.5 far fa-copy cursor-pointer text-gray-400/80 hover:text-primary',
|
||||
showInHover && 'group-hover:opacity-100 opacity-0',
|
||||
className
|
||||
)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(content.toString())
|
||||
.then(() => messageApi.success('Copied!'))
|
||||
.catch()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
import type { ReactHTMLProps } from "@lib/typings";
|
||||
import { Form, Input, InputRef } from "antd";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { SizeType } from "antd/lib/config-provider/SizeContext";
|
||||
import type { Rule } from "rc-field-form/lib/interface";
|
||||
|
||||
export interface EditableTextProps extends Omit<ReactHTMLProps<HTMLSpanElement>, 'onChange' | 'children'> {
|
||||
content?: string
|
||||
rootClassName?: string
|
||||
inputClassName?: string
|
||||
inputWidth?: string | number | undefined
|
||||
inputSize?: SizeType
|
||||
rules?: Rule[]
|
||||
onChange?: (val: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function EditableText(props: EditableTextProps) {
|
||||
const {
|
||||
rootClassName,
|
||||
disabled,
|
||||
inputClassName,
|
||||
rules,
|
||||
inputSize,
|
||||
inputWidth,
|
||||
onChange,
|
||||
content,
|
||||
...rest
|
||||
} = props
|
||||
const [ editMode, setEditMode ] = React.useState(false)
|
||||
const inputRef = React.useRef<InputRef | null>(null)
|
||||
const [ val, setVal ] = React.useState(content)
|
||||
React.useEffect(() => {
|
||||
const { input } = inputRef.current || {}
|
||||
if (input) {
|
||||
input.value = val || ''
|
||||
}
|
||||
}, [ val ])
|
||||
const [ form ] = Form.useForm()
|
||||
return (
|
||||
<div className={twMerge('group block', rootClassName)}>
|
||||
<span {...rest} className={twMerge(
|
||||
editMode ? 'hidden' : 'flex items-center gap-x-2',
|
||||
'leading-none'
|
||||
)}>
|
||||
{val}
|
||||
<i
|
||||
className={'fal fa-pen-to-square text-sm opacity-0 group-hover:opacity-100 text-neutral-400 hover:text-primary cursor-pointer'}
|
||||
onClick={() => setEditMode(true)}
|
||||
/>
|
||||
</span>
|
||||
<Form
|
||||
rootClassName={twMerge(editMode ? 'block' : 'hidden')}
|
||||
form={form}
|
||||
onFinish={() => {
|
||||
setEditMode(false)
|
||||
const newVal = inputRef.current?.input?.value || ''
|
||||
onChange && onChange(newVal)
|
||||
setVal(newVal)
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name={'input'}
|
||||
rules={rules}
|
||||
rootClassName={'m-0'}
|
||||
className={'[&>.ant-row>.ant-col>div>.ant-form-item-explain]:hidden'}
|
||||
>
|
||||
<Input
|
||||
disabled={disabled}
|
||||
size={inputSize || 'small'}
|
||||
ref={inputRef}
|
||||
style={{ width: inputWidth }}
|
||||
defaultValue={val}
|
||||
className={inputClassName}
|
||||
onKeyDown={async (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
form.submit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ReactHTMLProps } from "@lib/typings";
|
||||
|
||||
export interface MiddleEllipsisProps extends ReactHTMLProps<HTMLSpanElement> {
|
||||
content: string
|
||||
maxLength: number
|
||||
rootClassName?: string
|
||||
}
|
||||
|
||||
export function MiddleEllipsis(props: MiddleEllipsisProps) {
|
||||
|
||||
const { content, maxLength, className, rootClassName, ...rest } = props
|
||||
|
||||
const [ leftL, rightL ] = React.useMemo(() => {
|
||||
const left = Math.floor(maxLength / 2)
|
||||
const right = Math.ceil(maxLength / 2)
|
||||
return [ left, right ]
|
||||
}, [ maxLength ])
|
||||
|
||||
const [ left, right ] = React.useMemo(() => {
|
||||
if (content?.length <= maxLength) return [ content, '' ]
|
||||
return [ content.slice(0, leftL), content.slice(content?.length - rightL) ]
|
||||
}, [ content, leftL, rightL ])
|
||||
|
||||
return (
|
||||
<span {...rest} className={rootClassName}>
|
||||
{left}
|
||||
<span className={twMerge('text-gray-400', className)}> ... </span>
|
||||
{right}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Badge } from "antd";
|
||||
import React from "react";
|
||||
import { BadgeProps } from "antd/es/badge";
|
||||
|
||||
export interface StatusBadgeProps extends Omit<BadgeProps, 'status'> {
|
||||
status: 'up' | 'down'
|
||||
}
|
||||
|
||||
export default function StatusBadge(props: StatusBadgeProps) {
|
||||
const { status,...rest } = props
|
||||
return (
|
||||
<Badge
|
||||
size={'small'}
|
||||
color={status === 'up' ? 'rgb(82, 196, 26)' : 'rgb(255, 77, 79)'}
|
||||
text={status === 'up' ? 'Running' : 'Stopped'}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { WgServer } from "@lib/typings";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
export interface ServerIconProps {
|
||||
type: WgServer['type']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function ServerIcon(props: ServerIconProps) {
|
||||
return (
|
||||
<div className={twMerge('flex items-start', props.className)}>
|
||||
<div className={'w-fit h-full relative'}>
|
||||
<Image
|
||||
src={'/vps.29373866.svg'}
|
||||
alt={'VPS'}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
{props.type !== 'direct' && (
|
||||
<div className={'absolute -bottom-1 -right-2 rounded-full bg-white'}>
|
||||
{props.type === 'tor' && (
|
||||
<Image
|
||||
src={'/tor-onion.svg'}
|
||||
alt={'Tor'}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import Image, { ImageProps } from "next/image";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import React from "react";
|
||||
|
||||
export default function TorOnion(props: Omit<ImageProps, 'src' | 'alt'>) {
|
||||
return (
|
||||
<Image
|
||||
width={20}
|
||||
height={20}
|
||||
{...props}
|
||||
alt={'Tor'}
|
||||
className={twMerge('inline-block', props.className)}
|
||||
src={'/tor-onion.svg'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import React from "react";
|
||||
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
|
||||
import { Button, Form, Input, notification } from "antd";
|
||||
import { z } from "zod";
|
||||
import { APIResponse } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { NameSchema } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
import { RLS_NAME_INPUT } from "@lib/form-rules";
|
||||
|
||||
|
||||
type CreateClientModalProps = {
|
||||
serverId: string
|
||||
refreshTrigger: () => void
|
||||
}
|
||||
|
||||
const CreateClientModal = React.forwardRef<
|
||||
SmartModalRef,
|
||||
CreateClientModalProps
|
||||
>((props, ref) => {
|
||||
|
||||
|
||||
const [ notificationApi, contextHolder ] = notification.useNotification()
|
||||
|
||||
const innerRef = React.useRef<SmartModalRef | null>(null)
|
||||
const [ form ] = Form.useForm()
|
||||
|
||||
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
|
||||
|
||||
React.useEffect(() => {
|
||||
form?.resetFields()
|
||||
}, [])
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
`/api/wireguard/${props.serverId}/createClient`,
|
||||
async (url: string, { arg }: { arg: FormValues }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
return true
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
props.refreshTrigger()
|
||||
notificationApi.success({
|
||||
message: 'Success',
|
||||
description: 'Client has been created!'
|
||||
})
|
||||
innerRef.current?.close()
|
||||
form?.resetFields()
|
||||
},
|
||||
onError: () => {
|
||||
props.refreshTrigger()
|
||||
notificationApi.error({
|
||||
message: 'Error',
|
||||
description: 'Failed to create Client'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onFinish = (values: Record<string, string | undefined>) => {
|
||||
if (isMutating) return
|
||||
const parsed = FormSchema.safeParse(values)
|
||||
if (!parsed.success) {
|
||||
console.error(zodErrorMessage(parsed.error))
|
||||
return;
|
||||
}
|
||||
trigger(values as FormValues).catch()
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartModal
|
||||
ref={innerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
rootClassName={'w-full max-w-[340px]'}
|
||||
>
|
||||
{contextHolder}
|
||||
<h4 className={'mb-6'}> Create Client </h4>
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
|
||||
<Form.Item name={'name'} label={'Name'} rules={RLS_NAME_INPUT}>
|
||||
<Input placeholder={'Unicorn 🦄'} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type={'primary'} htmlType={'submit'} className={'w-full'} loading={isMutating}>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</SmartModal>
|
||||
)
|
||||
})
|
||||
|
||||
export default CreateClientModal
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: NameSchema
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
@@ -1,207 +0,0 @@
|
||||
import React from "react";
|
||||
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
|
||||
import { Button, Form, Input, notification, Segmented } from "antd";
|
||||
import { z } from "zod";
|
||||
import { APIResponse } from "@lib/typings";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { isPrivateIP } from "@lib/utils";
|
||||
import { AddressSchema, DnsSchema, MtuSchema, NameSchema, PortSchema, TypeSchema } from "@lib/schemas/WireGuard";
|
||||
import { zodErrorMessage } from "@lib/zod";
|
||||
import TorOnion from "@ui/icons/TorOnionIcon";
|
||||
|
||||
type CreateServerModalProps = {
|
||||
refreshTrigger: () => void
|
||||
}
|
||||
|
||||
const CreateServerModal = React.forwardRef<
|
||||
SmartModalRef,
|
||||
CreateServerModalProps
|
||||
>((props, ref) => {
|
||||
|
||||
|
||||
const [ notificationApi, contextHolder ] = notification.useNotification()
|
||||
|
||||
const innerRef = React.useRef<SmartModalRef | null>(null)
|
||||
const [ form ] = Form.useForm()
|
||||
|
||||
const [ type, setType ] = React.useState<ServerType>('direct')
|
||||
|
||||
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
|
||||
|
||||
React.useEffect(() => {
|
||||
form?.resetFields()
|
||||
}, [])
|
||||
|
||||
const { isMutating, trigger } = useSWRMutation(
|
||||
'/api/wireguard/createServer',
|
||||
async (url: string, { arg }: { arg: FormValues }) => {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(arg)
|
||||
})
|
||||
const data = await resp.json() as APIResponse<any>
|
||||
if (!data.ok) throw new Error('Server responded with error status')
|
||||
props.refreshTrigger()
|
||||
return data.result
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notificationApi.success({
|
||||
message: 'Success',
|
||||
description: 'Server has been created!'
|
||||
})
|
||||
innerRef.current?.close()
|
||||
form?.resetFields()
|
||||
},
|
||||
onError: () => {
|
||||
notificationApi.error({
|
||||
message: 'Error',
|
||||
description: 'Failed to create server'
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const onFinish = (values: Record<string, string | undefined>) => {
|
||||
if (isMutating) return
|
||||
const data = { ...values, type }
|
||||
const parsed = FormSchema.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
console.error(zodErrorMessage(parsed.error))
|
||||
return;
|
||||
}
|
||||
trigger(data as FormValues)
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartModal
|
||||
ref={innerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
rootClassName={'w-full max-w-[340px]'}
|
||||
>
|
||||
{contextHolder}
|
||||
<h4 className={'mb-6'}> Create Server </h4>
|
||||
<Form form={form} onFinish={onFinish}>
|
||||
|
||||
<Form.Item name={'name'} label={'Name'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Name is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = NameSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'Kitty World'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'address'} label={'Address'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Address is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (value && !isPrivateIP(value)) {
|
||||
return Promise.reject('Address must be a private IP address')
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'10.0.0.1'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'port'} label={'Port'} rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Port is required'
|
||||
},
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const port = parseInt(value || '')
|
||||
if (port > 0 && port < 65535) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject('Port must be a valid port number')
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'51820'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={'Server Mode'}>
|
||||
<Segmented
|
||||
className={'select-none'}
|
||||
defaultValue={type}
|
||||
onChange={(v) => setType(v as any)}
|
||||
options={[
|
||||
{ label: 'Direct', value: 'direct', icon: <i className={'fal fa-arrows-left-right-to-line'} /> },
|
||||
{ label: 'Tor', value: 'tor', icon: <TorOnion width={18} height={18} /> }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'dns'} label={'DNS'} rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = DnsSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'dns.google'} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={'mtu'} label={'MTU'} rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value) return Promise.resolve()
|
||||
const res = MtuSchema.safeParse(value)
|
||||
if (res.success) return Promise.resolve()
|
||||
return Promise.reject(zodErrorMessage(res.error)[0])
|
||||
}
|
||||
}
|
||||
]}>
|
||||
<Input placeholder={'1420'} />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type={'primary'}
|
||||
htmlType={'submit'}
|
||||
className={'w-full'}
|
||||
loading={isMutating}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
|
||||
</Form>
|
||||
</SmartModal>
|
||||
)
|
||||
})
|
||||
|
||||
export default CreateServerModal
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: NameSchema,
|
||||
address: AddressSchema,
|
||||
port: PortSchema,
|
||||
type: TypeSchema,
|
||||
dns: DnsSchema,
|
||||
mtu: MtuSchema
|
||||
})
|
||||
|
||||
type ServerType = z.infer<typeof TypeSchema>
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from "react";
|
||||
import SmartModal, { SmartModalRef } from "@ui/modal/SmartModal";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
import { SHA1 } from "crypto-js";
|
||||
|
||||
type QRCodeModalProps = {
|
||||
content: string
|
||||
}
|
||||
|
||||
const QRCodeModal = React.forwardRef<
|
||||
SmartModalRef,
|
||||
QRCodeModalProps
|
||||
>((props, ref) => {
|
||||
|
||||
const innerRef = React.useRef<SmartModalRef | null>(null)
|
||||
|
||||
React.useImperativeHandle(ref, () => innerRef.current as SmartModalRef)
|
||||
|
||||
return (
|
||||
<SmartModal
|
||||
key={SHA1(props.content || '').toString()}
|
||||
ref={innerRef}
|
||||
title={null}
|
||||
footer={null}
|
||||
className={'flex items-center justify-center'}
|
||||
>
|
||||
<div className={'flex items-center justify-center p-5'}>
|
||||
<QRCodeCanvas
|
||||
size={256}
|
||||
level={'M'}
|
||||
value={props.content || ''}
|
||||
/>
|
||||
</div>
|
||||
</SmartModal>
|
||||
)
|
||||
})
|
||||
|
||||
export default QRCodeModal
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { Modal, type ModalProps } from "antd";
|
||||
|
||||
export type SmartModalProps = ModalProps
|
||||
|
||||
export type SmartModalRef = {
|
||||
open: () => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
const SmartModal = React.forwardRef<SmartModalRef, SmartModalProps>((props, ref) => {
|
||||
|
||||
const [ visible, setVisible ] = React.useState<boolean>(false)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: () => setVisible(true),
|
||||
close: () => setVisible(false),
|
||||
toggle: () => setVisible(!visible)
|
||||
}))
|
||||
|
||||
return (
|
||||
<Modal
|
||||
onCancel={() => setVisible(false)}
|
||||
onOk={() => setVisible(!visible)}
|
||||
{...props}
|
||||
open={visible}
|
||||
/>
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
export default SmartModal
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import clsx from "clsx";
|
||||
import PageHeader from "@ui/pages/PageHeader";
|
||||
import PageFooter from "@ui/pages/PageFooter";
|
||||
|
||||
export type BasePageProps = {
|
||||
rootClassName?: string
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function BasePage(props: BasePageProps): React.ReactElement {
|
||||
return (
|
||||
<div className={clsx(
|
||||
'w-full min-h-screen flex justify-center',
|
||||
'px-2 md:px-6 py-2'
|
||||
)}>
|
||||
<div className={twMerge(
|
||||
'w-full mx-auto max-w-3xl',
|
||||
'space-y-3.5',
|
||||
props.rootClassName
|
||||
)}>
|
||||
<PageHeader />
|
||||
<main className={twMerge('py-2', props.className)}>
|
||||
{props.children}
|
||||
</main>
|
||||
<PageFooter />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ReactHTMLProps } from "@lib/typings";
|
||||
|
||||
export type PageFooterProps = {}
|
||||
|
||||
export default function PageFooter(props: PageFooterProps) {
|
||||
return (
|
||||
<footer className={'flex items-center justify-center'}>
|
||||
<Link
|
||||
href={'https://github.com/shahradelahi'}
|
||||
title={'Find me on Github'}
|
||||
className={'px-2 font-medium text-gray-400/80 hover:text-gray-500 text-xs'}
|
||||
>
|
||||
Made by <span className={'font-medium'}> Shahrad Elahi </span>
|
||||
</Link>
|
||||
<DotDivider className={'font-bold text-gray-400'} />
|
||||
<Link
|
||||
href={'https://github.com/shahradelahi/wireadmin'}
|
||||
title={'Github'}
|
||||
className={'px-2 font-medium text-gray-400/80 hover:text-gray-500 text-xs'}
|
||||
>
|
||||
Github
|
||||
</Link>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
function DotDivider(props: ReactHTMLProps<HTMLSpanElement>) {
|
||||
return <span {...props}> · </span>
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export type PageHeaderProps = {}
|
||||
|
||||
export default function PageHeader(props: PageHeaderProps) {
|
||||
return (
|
||||
<header className={'w-full py-3 px-2'}>
|
||||
<nav className={'w-full flex items-center justify-between'}>
|
||||
|
||||
<div className={'flex items-center gap-x-2 text-3xl font-medium'}>
|
||||
<Image
|
||||
src={'/logo.png'}
|
||||
alt={'WireAdmin'}
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<h1> WireAdmin </h1>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center gap-x-2'}>
|
||||
<Link
|
||||
href={'https://github.com/shahradelahi/wireadmin'}
|
||||
title={'Giv me a star on Github'}
|
||||
>
|
||||
<img
|
||||
src={'https://img.shields.io/github/stars/shahradelahi/wireadmin.svg?style=social&label=Star'}
|
||||
alt={'Giv me a star on Github'}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { LeastOne, ReactHTMLProps } from "@lib/typings";
|
||||
import React from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Breadcrumb } from "antd";
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
|
||||
export type PageRouterProps = ReactHTMLProps<HTMLDivElement> & LeastOne<{
|
||||
children: React.ReactNode
|
||||
route: RouteItem[]
|
||||
}>
|
||||
|
||||
type RouteItem = {
|
||||
href?: string
|
||||
title: React.ReactNode
|
||||
}
|
||||
|
||||
export default function PageRouter(props: PageRouterProps) {
|
||||
const { children, route, className, ...rest } = props
|
||||
return (
|
||||
<div {...rest} className={twMerge('py-3 px-2', className)}>
|
||||
{route && route.length > 0 && (
|
||||
<Breadcrumb items={[
|
||||
{
|
||||
href: '/',
|
||||
title: (
|
||||
<>
|
||||
<HomeOutlined />
|
||||
<span>Home</span>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...route
|
||||
]} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
web/.gitignore
vendored
Normal file
10
web/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
1
web/.npmrc
Normal file
1
web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
13
web/.prettierignore
Normal file
13
web/.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
21
web/.prettierrc
Normal file
21
web/.prettierrc
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"pluginSearchDirs": [
|
||||
"."
|
||||
],
|
||||
"tabWidth": 2,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
41
web/README.md
Normal file
41
web/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered
|
||||
by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm create svelte@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm create svelte@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a
|
||||
development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target
|
||||
> environment.
|
||||
BIN
web/bun.lockb
Normal file
BIN
web/bun.lockb
Normal file
Binary file not shown.
27
web/package.json
Normal file
27
web/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check .",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"start": "bun ./build/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^2.0.0",
|
||||
"@sveltejs/kit": "^1.20.4",
|
||||
"prettier": "^2.8.0",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.4.2"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
12
web/src/app.d.ts
vendored
Normal file
12
web/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
web/src/app.html
Normal file
12
web/src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
1
web/src/lib/index.ts
Normal file
1
web/src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
2
web/src/routes/+page.svelte
Normal file
2
web/src/routes/+page.svelte
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
BIN
web/static/favicon.png
Normal file
BIN
web/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user