Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cff64975d7 | |||
| 8a0cc68a22 | |||
|
|
de0cf3f4e6 | ||
|
|
b7c593c827 | ||
|
|
ae060a2a37 | ||
|
|
c1f4731669 |
39
Makefile
Normal file
39
Makefile
Normal file
@ -0,0 +1,39 @@
|
||||
DB_URL=postgresql://root:secret@localhost:5432/short_url?sslmode=disable
|
||||
|
||||
network:
|
||||
docker network create url-short-network
|
||||
|
||||
redis:
|
||||
docker run --name rd -d -p 6379:6379 redis:7.2.3 --requirepass "secret"
|
||||
|
||||
postgres:
|
||||
docker run --name postgres --network url-short-network -p 5432:5432 -e POSTGRES_USER=root -e POSTGRES_PASSWORD=secret -d postgres:16-alpine
|
||||
|
||||
createdb:
|
||||
docker exec -it postgres createdb --username=root --owner=root short_url
|
||||
|
||||
dropdb:
|
||||
docker exec -it postgres dropdb short_url
|
||||
|
||||
psql:
|
||||
docker exec -it postgres psql -U root -d short_url
|
||||
|
||||
migrateinit:
|
||||
migrate create -ext sql -dir db/schema -seq init_schema
|
||||
|
||||
migrateup:
|
||||
migrate -path db/schema -database "$(DB_URL)" -verbose up
|
||||
|
||||
migratedown:
|
||||
migrate -path db/schema -database "$(DB_URL)" -verbose down
|
||||
|
||||
sqlc:
|
||||
sqlc generate
|
||||
|
||||
test:
|
||||
go test -v -cover ./...
|
||||
|
||||
server:
|
||||
go run main.go
|
||||
|
||||
.PHONY: network redis postgres createdb dropdb psql migrateup migratedown sqlc test server
|
||||
31
db/db.go
Normal file
31
db/db.go
Normal file
@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.24.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
27
db/models.go
Normal file
27
db/models.go
Normal file
@ -0,0 +1,27 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.24.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UserRelateUrl struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ShortUrl string `json:"short_url"`
|
||||
OriginUrl string `json:"origin_url"`
|
||||
Status int32 `json:"status"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
24
db/querier.go
Normal file
24
db/querier.go
Normal file
@ -0,0 +1,24 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.24.0
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
CreateUser(ctx context.Context, arg *CreateUserParams) (*User, error)
|
||||
CreateUserUrl(ctx context.Context, arg *CreateUserUrlParams) (*UserRelateUrl, error)
|
||||
DeleteUser(ctx context.Context, id string) error
|
||||
GetUser(ctx context.Context, id string) (*User, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetUserByName(ctx context.Context, username string) (*User, error)
|
||||
ListUrlByUser(ctx context.Context, userID string) ([]*UserRelateUrl, error)
|
||||
ListUsers(ctx context.Context, arg *ListUsersParams) ([]*User, error)
|
||||
UpdateStatus(ctx context.Context, arg *UpdateStatusParams) (*UserRelateUrl, error)
|
||||
UpdateUser(ctx context.Context, arg *UpdateUserParams) (*User, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
36
db/query/user.sql
Normal file
36
db/query/user.sql
Normal file
@ -0,0 +1,36 @@
|
||||
-- name: CreateUser :one
|
||||
INSERT INTO users (
|
||||
id, username, hashed_password, email
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET hashed_password = $2,
|
||||
email = $3
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetUser :one
|
||||
SELECT * FROM users
|
||||
WHERE id = $1 LIMIT 1;
|
||||
|
||||
-- name: GetUserByName :one
|
||||
SELECT * FROM users
|
||||
WHERE username = $1 LIMIT 1;
|
||||
|
||||
-- name: GetUserByEmail :one
|
||||
SELECT * FROM users
|
||||
WHERE email = $1 LIMIT 1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT * FROM users
|
||||
ORDER BY id
|
||||
LIMIT $1
|
||||
OFFSET $2;
|
||||
19
db/query/user_relate_url.sql
Normal file
19
db/query/user_relate_url.sql
Normal file
@ -0,0 +1,19 @@
|
||||
-- name: CreateUserUrl :one
|
||||
INSERT INTO user_relate_url (
|
||||
user_id, short_url, origin_url, status, expire_at
|
||||
) VALUES (
|
||||
$1, $2, $3, 0, $4
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateStatus :one
|
||||
UPDATE user_relate_url
|
||||
SET status = $2
|
||||
WHERE short_url = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListUrlByUser :many
|
||||
SELECT *
|
||||
FROM user_relate_url
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC;
|
||||
2
db/schema/000001_init_schema.down.sql
Normal file
2
db/schema/000001_init_schema.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
DROP TABLE "user_relate_url";
|
||||
DROP TABLE "users";
|
||||
27
db/schema/000001_init_schema.up.sql
Normal file
27
db/schema/000001_init_schema.up.sql
Normal file
@ -0,0 +1,27 @@
|
||||
CREATE TABLE "users" (
|
||||
"id" varchar NOT NULL PRIMARY KEY,
|
||||
"username" varchar NOT NULL,
|
||||
"hashed_password" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT (now())
|
||||
);
|
||||
|
||||
ALTER TABLE "users" ADD CONSTRAINT "username_key" UNIQUE ("username");
|
||||
ALTER TABLE "users" ADD CONSTRAINT "email_key" UNIQUE ("email");
|
||||
CREATE INDEX ON "users" ("username");
|
||||
CREATE INDEX ON "users" ("email");
|
||||
|
||||
|
||||
CREATE TABLE "user_relate_url" (
|
||||
"id" bigserial NOT NULL PRIMARY KEY,
|
||||
"user_id" varchar NOT NULL,
|
||||
"short_url" varchar NOT NULL,
|
||||
"origin_url" varchar NOT NULL,
|
||||
"status" int NOT NULL DEFAULT 0,
|
||||
"expire_at" timestamptz NOT NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT (now())
|
||||
);
|
||||
|
||||
ALTER TABLE "user_relate_url" ADD CONSTRAINT "short_url_key" UNIQUE ("short_url");
|
||||
CREATE INDEX ON "user_relate_url" ("user_id");
|
||||
CREATE INDEX ON "user_relate_url" ("short_url");
|
||||
61
db/store.go
Normal file
61
db/store.go
Normal file
@ -0,0 +1,61 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Querier
|
||||
ExecTx(ctx context.Context, fn func(*Queries) error) error
|
||||
IsUniqueViolation(err error) bool
|
||||
IsForeignKeyViolation(err error) bool
|
||||
IsNoRows(err error) bool
|
||||
}
|
||||
|
||||
type SQLStore struct {
|
||||
db *sql.DB
|
||||
*Queries
|
||||
}
|
||||
|
||||
func NewStore(db *sql.DB) Store {
|
||||
return &SQLStore{
|
||||
db: db,
|
||||
Queries: New(db),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *SQLStore) ExecTx(ctx context.Context, fn func(*Queries) error) error {
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := New(tx)
|
||||
err = fn(q)
|
||||
if err != nil {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
return fmt.Errorf("tx err: %v, rb err: %v", err, rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *SQLStore) IsUniqueViolation(err error) bool {
|
||||
pqErr, ok := err.(*pq.Error)
|
||||
return ok && pqErr.Code == "23505"
|
||||
}
|
||||
|
||||
func (store *SQLStore) IsForeignKeyViolation(err error) bool {
|
||||
pqErr, ok := err.(*pq.Error)
|
||||
return ok && pqErr.Code == "23503"
|
||||
}
|
||||
|
||||
func (store *SQLStore) IsNoRows(err error) bool {
|
||||
return err == sql.ErrNoRows
|
||||
}
|
||||
176
db/user.sql.go
Normal file
176
db/user.sql.go
Normal file
@ -0,0 +1,176 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.24.0
|
||||
// source: user.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createUser = `-- name: CreateUser :one
|
||||
INSERT INTO users (
|
||||
id, username, hashed_password, email
|
||||
) VALUES (
|
||||
$1, $2, $3, $4
|
||||
)
|
||||
RETURNING id, username, hashed_password, email, created_at
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg *CreateUserParams) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUser,
|
||||
arg.ID,
|
||||
arg.Username,
|
||||
arg.HashedPassword,
|
||||
arg.Email,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const deleteUser = `-- name: DeleteUser :exec
|
||||
DELETE FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteUser(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteUser, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT id, username, hashed_password, email, created_at FROM users
|
||||
WHERE id = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUser(ctx context.Context, id string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUser, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||
SELECT id, username, hashed_password, email, created_at FROM users
|
||||
WHERE email = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByEmail, email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getUserByName = `-- name: GetUserByName :one
|
||||
SELECT id, username, hashed_password, email, created_at FROM users
|
||||
WHERE username = $1 LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserByName(ctx context.Context, username string) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByName, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, hashed_password, email, created_at FROM users
|
||||
ORDER BY id
|
||||
LIMIT $1
|
||||
OFFSET $2
|
||||
`
|
||||
|
||||
type ListUsersParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListUsers(ctx context.Context, arg *ListUsersParams) ([]*User, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUsers, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*User{}
|
||||
for rows.Next() {
|
||||
var i User
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUser = `-- name: UpdateUser :one
|
||||
UPDATE users
|
||||
SET hashed_password = $2,
|
||||
email = $3
|
||||
WHERE id = $1
|
||||
RETURNING id, username, hashed_password, email, created_at
|
||||
`
|
||||
|
||||
type UpdateUserParams struct {
|
||||
ID string `json:"id"`
|
||||
HashedPassword string `json:"hashed_password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUser(ctx context.Context, arg *UpdateUserParams) (*User, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateUser, arg.ID, arg.HashedPassword, arg.Email)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.HashedPassword,
|
||||
&i.Email,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
112
db/user_relate_url.sql.go
Normal file
112
db/user_relate_url.sql.go
Normal file
@ -0,0 +1,112 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.24.0
|
||||
// source: user_relate_url.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const createUserUrl = `-- name: CreateUserUrl :one
|
||||
INSERT INTO user_relate_url (
|
||||
user_id, short_url, origin_url, status, expire_at
|
||||
) VALUES (
|
||||
$1, $2, $3, 0, $4
|
||||
)
|
||||
RETURNING id, user_id, short_url, origin_url, status, expire_at, created_at
|
||||
`
|
||||
|
||||
type CreateUserUrlParams struct {
|
||||
UserID string `json:"user_id"`
|
||||
ShortUrl string `json:"short_url"`
|
||||
OriginUrl string `json:"origin_url"`
|
||||
ExpireAt time.Time `json:"expire_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUserUrl(ctx context.Context, arg *CreateUserUrlParams) (*UserRelateUrl, error) {
|
||||
row := q.db.QueryRowContext(ctx, createUserUrl,
|
||||
arg.UserID,
|
||||
arg.ShortUrl,
|
||||
arg.OriginUrl,
|
||||
arg.ExpireAt,
|
||||
)
|
||||
var i UserRelateUrl
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ShortUrl,
|
||||
&i.OriginUrl,
|
||||
&i.Status,
|
||||
&i.ExpireAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const listUrlByUser = `-- name: ListUrlByUser :many
|
||||
SELECT id, user_id, short_url, origin_url, status, expire_at, created_at
|
||||
FROM user_relate_url
|
||||
WHERE user_id = $1
|
||||
ORDER BY id DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListUrlByUser(ctx context.Context, userID string) ([]*UserRelateUrl, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUrlByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*UserRelateUrl{}
|
||||
for rows.Next() {
|
||||
var i UserRelateUrl
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ShortUrl,
|
||||
&i.OriginUrl,
|
||||
&i.Status,
|
||||
&i.ExpireAt,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateStatus = `-- name: UpdateStatus :one
|
||||
UPDATE user_relate_url
|
||||
SET status = $2
|
||||
WHERE short_url = $1
|
||||
RETURNING id, user_id, short_url, origin_url, status, expire_at, created_at
|
||||
`
|
||||
|
||||
type UpdateStatusParams struct {
|
||||
ShortUrl string `json:"short_url"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateStatus(ctx context.Context, arg *UpdateStatusParams) (*UserRelateUrl, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateStatus, arg.ShortUrl, arg.Status)
|
||||
var i UserRelateUrl
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.ShortUrl,
|
||||
&i.OriginUrl,
|
||||
&i.Status,
|
||||
&i.ExpireAt,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
21
go.mod
21
go.mod
@ -1,19 +1,32 @@
|
||||
module github.com/zhang2092/go-url-shortener
|
||||
|
||||
go 1.21.5
|
||||
go 1.23.3
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/handlers v1.5.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/itchyny/base58-go v0.2.1
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/itchyny/base58-go v0.2.2
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/redis/go-redis/v9 v9.3.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
42
go.sum
42
go.sum
@ -1,26 +1,56 @@
|
||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/itchyny/base58-go v0.2.1 h1:wtnhAVdOcW3WuHEASmGHMms4juOB8yEpj/KJxlB57+k=
|
||||
github.com/itchyny/base58-go v0.2.1/go.mod h1:BNvrKeAtWNSca1GohNbyhfff9/v0IrZjzWCAGeAvZZE=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/itchyny/base58-go v0.2.2 h1:pswMT6rW2nRoELk5Mi8+xGLQPmDnlNnCwbfRCl2p7Mo=
|
||||
github.com/itchyny/base58-go v0.2.2/go.mod h1:e7aEDHyQXm42jniwyoi+MaUeUdeWp58C5H20rTe52co=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.3.1 h1:KqdY8U+3X6z+iACvumCNxnoluToB+9Me+TvyFa21Mds=
|
||||
github.com/redis/go-redis/v9 v9.3.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
|
||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
199
handler/account.go
Normal file
199
handler/account.go
Normal file
@ -0,0 +1,199 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/zhang2092/go-url-shortener/db"
|
||||
"github.com/zhang2092/go-url-shortener/pkg/cookie"
|
||||
pwd "github.com/zhang2092/go-url-shortener/pkg/password"
|
||||
)
|
||||
|
||||
type registerPageData struct {
|
||||
Summary string
|
||||
Email string
|
||||
EmailMsg string
|
||||
Username string
|
||||
UsernameMsg string
|
||||
Password string
|
||||
PasswordMsg string
|
||||
}
|
||||
|
||||
func RegisterView(templates fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
renderRegister(w, r, templates, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func Register(templates fs.FS, store db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
email := r.PostFormValue("email")
|
||||
username := r.PostFormValue("username")
|
||||
password := r.PostFormValue("password")
|
||||
resp, ok := viladatorRegister(email, username, password)
|
||||
if !ok {
|
||||
renderRegister(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := pwd.BcryptHashPassword(password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
arg := &db.CreateUserParams{
|
||||
ID: genId(),
|
||||
Username: username,
|
||||
HashedPassword: hashedPassword,
|
||||
Email: email,
|
||||
}
|
||||
|
||||
_, err = store.CreateUser(r.Context(), arg)
|
||||
if err != nil {
|
||||
if store.IsUniqueViolation(err) {
|
||||
resp.Summary = "邮箱或名称已经存在"
|
||||
renderRegister(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Summary = "请求网络错误,请刷新重试"
|
||||
renderRegister(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
type loginPageData struct {
|
||||
Summary string
|
||||
Email string
|
||||
EmailMsg string
|
||||
Password string
|
||||
PasswordMsg string
|
||||
}
|
||||
|
||||
func LoginView(templates fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
renderLogin(w, r, templates, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func Login(templates fs.FS, store db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderLogin(w, r, templates, registerPageData{Summary: "请求网络错误,请刷新重试"})
|
||||
return
|
||||
}
|
||||
|
||||
email := r.PostFormValue("email")
|
||||
password := r.PostFormValue("password")
|
||||
resp, ok := viladatorLogin(email, password)
|
||||
if !ok {
|
||||
renderLogin(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
user, err := store.GetUserByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if store.IsNoRows(sql.ErrNoRows) {
|
||||
resp.Summary = "邮箱或密码错误"
|
||||
renderLogin(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Summary = "请求网络错误,请刷新重试"
|
||||
renderLogin(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
err = pwd.BcryptComparePassword(user.HashedPassword, password)
|
||||
if err != nil {
|
||||
resp.Summary = "邮箱或密码错误"
|
||||
renderLogin(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := secureCookie.Encode(AuthorizeCookie, &Authorize{ID: user.ID, Name: user.Username})
|
||||
if err != nil {
|
||||
resp.Summary = "请求网络错误,请刷新重试(cookie)"
|
||||
renderLogin(w, r, templates, resp)
|
||||
return
|
||||
}
|
||||
|
||||
c := cookie.NewCookie(cookie.AuthorizeName, encoded, time.Now().Add(time.Duration(7200)*time.Second))
|
||||
http.SetCookie(w, c)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func Logout(templates fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie.DeleteCookie(w, cookie.AuthorizeName)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func renderRegister(w http.ResponseWriter, r *http.Request, templates fs.FS, data any) {
|
||||
renderLayout(w, r, templates, data, "user/register.html.tmpl")
|
||||
}
|
||||
|
||||
func renderLogin(w http.ResponseWriter, r *http.Request, templates fs.FS, data any) {
|
||||
renderLayout(w, r, templates, data, "user/login.html.tmpl")
|
||||
}
|
||||
|
||||
func viladatorRegister(email, username, password string) (registerPageData, bool) {
|
||||
ok := true
|
||||
resp := registerPageData{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
if !ValidateRxEmail(email) {
|
||||
resp.EmailMsg = "请填写正确的邮箱地址"
|
||||
ok = false
|
||||
}
|
||||
if !ValidateRxUsername(username) {
|
||||
resp.UsernameMsg = "名称(6-20,字母,数字)"
|
||||
ok = false
|
||||
}
|
||||
if !ValidatePassword(password) {
|
||||
resp.PasswordMsg = "密码(8-20位)"
|
||||
ok = false
|
||||
}
|
||||
|
||||
return resp, ok
|
||||
}
|
||||
|
||||
func viladatorLogin(email, password string) (loginPageData, bool) {
|
||||
ok := true
|
||||
errs := loginPageData{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
if !ValidateRxEmail(email) {
|
||||
errs.EmailMsg = "请填写正确的邮箱地址"
|
||||
ok = false
|
||||
}
|
||||
if len(password) == 0 {
|
||||
errs.PasswordMsg = "请填写正确的密码"
|
||||
ok = false
|
||||
}
|
||||
|
||||
return errs, ok
|
||||
}
|
||||
28
handler/base.go
Normal file
28
handler/base.go
Normal file
@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/securecookie"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorizeCookie = "authorize"
|
||||
ContextUser ctxKey = "context_user"
|
||||
)
|
||||
|
||||
var secureCookie *securecookie.SecureCookie
|
||||
|
||||
type ctxKey string
|
||||
|
||||
type Authorize struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func genId() string {
|
||||
return uuid.Must(uuid.NewRandom()).String()
|
||||
}
|
||||
|
||||
func SetSecureCookie(sc *securecookie.SecureCookie) {
|
||||
secureCookie = sc
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/zhang2092/go-url-shortener/shortener"
|
||||
"github.com/zhang2092/go-url-shortener/store"
|
||||
)
|
||||
|
||||
type UrlCreationRequest struct {
|
||||
LongUrl string `json:"long_url"`
|
||||
UserId string `json:"user_id"`
|
||||
}
|
||||
|
||||
type UrlCreationResponse struct {
|
||||
Message string `json:"message"`
|
||||
ShortUrl string `json:"short_url"`
|
||||
}
|
||||
|
||||
func CreateShortUrl(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
var req UrlCreationRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid parameter", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
shortUrl, err := shortener.GenerateShortLink(req.LongUrl, req.UserId)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to generate short link", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = store.SaveUrlMapping(shortUrl, req.LongUrl, req.UserId)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to store url mapping", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
scheme := "http://"
|
||||
if r.TLS != nil {
|
||||
scheme = "https://"
|
||||
}
|
||||
|
||||
res := &UrlCreationResponse{
|
||||
Message: "short url created successfully",
|
||||
ShortUrl: scheme + r.Host + "/" + shortUrl,
|
||||
}
|
||||
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
func HandleShortUrlRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
shorUrl := vars["shortUrl"]
|
||||
link, err := store.RetrieveInitialUrl(shorUrl)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get url", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(link) == 0 {
|
||||
http.Error(w, "short url get to long url is empty", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, link, http.StatusFound)
|
||||
}
|
||||
20
handler/home.go
Normal file
20
handler/home.go
Normal file
@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"github.com/zhang2092/go-url-shortener/db"
|
||||
)
|
||||
|
||||
func HomeView(templates fs.FS, store db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user := withUser(ctx)
|
||||
result, err := store.ListUrlByUser(ctx, user.ID)
|
||||
if err != nil {
|
||||
renderLayout(w, r, templates, nil, "home.html.tmpl")
|
||||
}
|
||||
renderLayout(w, r, templates, result, "home.html.tmpl")
|
||||
}
|
||||
}
|
||||
48
handler/middleware.go
Normal file
48
handler/middleware.go
Normal file
@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func MyAuthorize(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u := withUser(r.Context())
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func SetUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(AuthorizeCookie)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
u := Authorize{}
|
||||
err = secureCookie.Decode(AuthorizeCookie, cookie.Value, &u)
|
||||
if err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ContextUser, u)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func withUser(ctx context.Context) *Authorize {
|
||||
val := ctx.Value(ContextUser)
|
||||
if u, ok := val.(Authorize); ok {
|
||||
return &u
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
45
handler/render.go
Normal file
45
handler/render.go
Normal file
@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/zhang2092/go-url-shortener/pkg/logger"
|
||||
)
|
||||
|
||||
// renderLayout 渲染方法 带框架
|
||||
func renderLayout(w http.ResponseWriter, r *http.Request, templates fs.FS, data any, tmpl string) {
|
||||
t := template.New(filepath.Base(tmpl))
|
||||
t = t.Funcs(template.FuncMap{
|
||||
"csrfField": func() template.HTML {
|
||||
return csrf.TemplateField(r)
|
||||
},
|
||||
"currentUser": func() *Authorize {
|
||||
return withUser(r.Context())
|
||||
},
|
||||
"genShortUrl": func(url string) string {
|
||||
scheme := "http://"
|
||||
if r.TLS != nil {
|
||||
scheme = "https://"
|
||||
}
|
||||
return scheme + r.Host + "/" + url
|
||||
},
|
||||
})
|
||||
|
||||
tpl := template.Must(t.Clone())
|
||||
tpl, err := tpl.ParseFS(templates, tmpl, "base/header.html.tmpl", "base/footer.html.tmpl")
|
||||
if err != nil {
|
||||
logger.Logger.Errorf("template parse: %s, %v", tmpl, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tpl.Execute(w, data); err != nil {
|
||||
logger.Logger.Errorf("template execute: %s, %v", tmpl, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
55
handler/resp.go
Normal file
55
handler/resp.go
Normal file
@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func respond(w http.ResponseWriter, message string, v any, statusCode int) {
|
||||
rsp := response{
|
||||
Success: true,
|
||||
Message: message,
|
||||
Data: v,
|
||||
}
|
||||
b, err := json.Marshal(rsp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_, err = w.Write(b)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("could not write http response: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func RespondErr(w http.ResponseWriter, message string, v any) {
|
||||
rsp := response{
|
||||
Success: false,
|
||||
Message: message,
|
||||
Data: v,
|
||||
}
|
||||
b, err := json.Marshal(rsp)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, err = w.Write(b)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("could not write http response: %v\n", err)
|
||||
}
|
||||
}
|
||||
99
handler/short_url.go
Normal file
99
handler/short_url.go
Normal file
@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/zhang2092/go-url-shortener/db"
|
||||
"github.com/zhang2092/go-url-shortener/service"
|
||||
"github.com/zhang2092/go-url-shortener/shortener"
|
||||
)
|
||||
|
||||
func CreateShortUrlView(templates fs.FS) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
renderLayout(w, r, templates, nil, "short_url/create.html.tmpl")
|
||||
}
|
||||
}
|
||||
|
||||
func CreateShortUrl(templates fs.FS, store db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
renderCreateShortUrl(w, r, templates, map[string]string{"Error": "请求参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
user := withUser(ctx)
|
||||
longUrl := r.PostFormValue("long_url")
|
||||
shortUrl, err := shortener.GenerateShortLink(longUrl, user.ID)
|
||||
if err != nil {
|
||||
renderCreateShortUrl(w, r, templates, map[string]string{"Error": "生成短路径错误"})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = store.CreateUserUrl(ctx, &db.CreateUserUrlParams{
|
||||
UserID: user.ID,
|
||||
ShortUrl: shortUrl,
|
||||
OriginUrl: longUrl,
|
||||
ExpireAt: time.Now().Add(time.Hour * 6),
|
||||
})
|
||||
if err != nil {
|
||||
renderCreateShortUrl(w, r, templates, map[string]string{"Error": "短路径存储错误"})
|
||||
return
|
||||
}
|
||||
|
||||
err = service.SaveUrlMapping(shortUrl, longUrl, user.ID)
|
||||
if err != nil {
|
||||
renderCreateShortUrl(w, r, templates, map[string]string{"Error": "短路径存储错误"})
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteShortUrl(store db.Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
shorUrl := vars["shortUrl"]
|
||||
_, err := store.UpdateStatus(r.Context(), &db.UpdateStatusParams{
|
||||
ShortUrl: shorUrl,
|
||||
Status: -1,
|
||||
})
|
||||
if err != nil {
|
||||
RespondErr(w, "删除错误", nil)
|
||||
return
|
||||
}
|
||||
err = service.DeleteShortUrl(shorUrl)
|
||||
if err != nil {
|
||||
RespondErr(w, "删除错误", nil)
|
||||
return
|
||||
}
|
||||
|
||||
respond(w, "删除成功", nil, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleShortUrlRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
shorUrl := vars["shortUrl"]
|
||||
link, err := service.RetrieveInitialUrl(shorUrl)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to get url", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(link) == 0 {
|
||||
http.Error(w, "short url get to long url is empty", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, link, http.StatusFound)
|
||||
}
|
||||
|
||||
func renderCreateShortUrl(w http.ResponseWriter, r *http.Request, templates fs.FS, data any) {
|
||||
renderLayout(w, r, templates, data, "short_url/create.html.tmpl")
|
||||
}
|
||||
38
handler/validator.go
Normal file
38
handler/validator.go
Normal file
@ -0,0 +1,38 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
rxPhone = regexp.MustCompile(`^(13|14|15|16|17|18|19)\d{9}$`)
|
||||
rxEmail = regexp.MustCompile(`\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*`)
|
||||
rxUsername = regexp.MustCompile(`^[a-z0-9A-Z]{6,20}$`) // 6到20位(字母,数字)
|
||||
//rxPassword = regexp.MustCompile(`^(?=.*[a-zA-Z])(?=.*[0-9])[A-Za-z0-9]{8,18}$`) // 最少6位,包括至少1个大写字母,1个小写字母,1个数字,1个特殊字符
|
||||
)
|
||||
|
||||
func ValidateRxPhone(phone string) bool {
|
||||
phone = strings.TrimSpace(phone)
|
||||
return rxPhone.MatchString(phone)
|
||||
}
|
||||
|
||||
func ValidateRxEmail(email string) bool {
|
||||
email = strings.TrimSpace(email)
|
||||
return rxEmail.MatchString(email)
|
||||
}
|
||||
|
||||
func ValidateRxUsername(username string) bool {
|
||||
username = strings.TrimSpace(username)
|
||||
return rxUsername.MatchString(username)
|
||||
}
|
||||
|
||||
// func ValidateRxPassword(password string) bool {
|
||||
// password = strings.TrimSpace(password)
|
||||
// return rxPassword.MatchString(password)
|
||||
// }
|
||||
|
||||
func ValidatePassword(password string) bool {
|
||||
password = strings.TrimSpace(password)
|
||||
return len(password) >= 8 && len(password) <= 20
|
||||
}
|
||||
6
log/run.log
Normal file
6
log/run.log
Normal file
@ -0,0 +1,6 @@
|
||||
2023-12-25T15:32:56.864+0800 ERROR handler/render.go:28 template parse: user/login.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
2023-12-25T15:32:57.541+0800 ERROR handler/render.go:28 template parse: user/login.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
2023-12-25T15:33:21.637+0800 ERROR handler/render.go:28 template parse: user/login.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
2023-12-25T15:33:56.806+0800 ERROR handler/render.go:28 template parse: user/register.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
2023-12-25T15:34:12.152+0800 ERROR handler/render.go:28 template parse: user/login.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
2023-12-25T15:34:59.057+0800 ERROR handler/render.go:31 template parse: user/register.html.tmpl, template: header.html.tmpl:22: function "currentUser" not defined
|
||||
84
main.go
84
main.go
@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -10,12 +13,23 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
hds "github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/zhang2092/go-url-shortener/db"
|
||||
"github.com/zhang2092/go-url-shortener/handler"
|
||||
"github.com/zhang2092/go-url-shortener/store"
|
||||
"github.com/zhang2092/go-url-shortener/pkg/logger"
|
||||
"github.com/zhang2092/go-url-shortener/service"
|
||||
)
|
||||
|
||||
//go:embed web/template
|
||||
var templateFS embed.FS
|
||||
|
||||
//go:embed web/static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
var local bool
|
||||
flag.BoolVar(&local, "debug", true, "server running in debug?")
|
||||
@ -27,20 +41,71 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
logger.NewLogger()
|
||||
|
||||
// Set up templates
|
||||
templates, err := fs.Sub(templateFS, "web/template")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Set up statics
|
||||
statics, err := fs.Sub(staticFS, "web/static")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
addr := os.Getenv("REDIS_ADDR")
|
||||
password := os.Getenv("REDIS_PASSWORD")
|
||||
db, err := strconv.Atoi(os.Getenv("REDIS_DB"))
|
||||
redisDb, err := strconv.Atoi(os.Getenv("REDIS_DB"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get redis db index: %v", err)
|
||||
}
|
||||
store.InitializeStore(addr, password, db)
|
||||
service.InitializeStore(addr, password, redisDb)
|
||||
|
||||
conn, err := sql.Open(os.Getenv("DB_DRIVER"), os.Getenv("DB_SOURCE"))
|
||||
if err != nil {
|
||||
log.Fatal("cannot connect to db: ", err)
|
||||
}
|
||||
store := db.NewStore(conn)
|
||||
|
||||
hashKey := securecookie.GenerateRandomKey(32)
|
||||
blockKey := securecookie.GenerateRandomKey(32)
|
||||
handler.SetSecureCookie(securecookie.New(hashKey, blockKey))
|
||||
|
||||
router := mux.NewRouter()
|
||||
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Wecome to the URL Shortener API"))
|
||||
}).Methods(http.MethodGet)
|
||||
router.HandleFunc("/create-short-url", handler.CreateShortUrl).Methods(http.MethodPost)
|
||||
router.Use(mux.CORSMethodMiddleware(router))
|
||||
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(statics))))
|
||||
|
||||
csrfMiddleware := csrf.Protect(
|
||||
[]byte(securecookie.GenerateRandomKey(32)),
|
||||
csrf.Secure(false),
|
||||
csrf.HttpOnly(true),
|
||||
csrf.FieldName("csrf_token"),
|
||||
csrf.CookieName("authorize_csrf"),
|
||||
)
|
||||
router.Use(csrfMiddleware)
|
||||
router.Use(handler.SetUser)
|
||||
|
||||
router.Handle("/register", hds.MethodHandler{
|
||||
http.MethodGet: http.Handler(handler.RegisterView(templates)),
|
||||
http.MethodPost: http.Handler(handler.Register(templates, store)),
|
||||
})
|
||||
router.Handle("/login", hds.MethodHandler{
|
||||
http.MethodGet: http.Handler(handler.LoginView(templates)),
|
||||
http.MethodPost: http.Handler(handler.Login(templates, store)),
|
||||
})
|
||||
router.Handle("/logout", handler.Logout(templates)).Methods(http.MethodGet)
|
||||
|
||||
subRouter := router.PathPrefix("/").Subrouter()
|
||||
subRouter.Use(handler.MyAuthorize)
|
||||
subRouter.Handle("/", handler.HomeView(templates, store)).Methods(http.MethodGet)
|
||||
subRouter.Handle("/create-short-url", hds.MethodHandler{
|
||||
http.MethodGet: http.Handler(handler.CreateShortUrlView(templates)),
|
||||
http.MethodPost: http.Handler(handler.CreateShortUrl(templates, store)),
|
||||
})
|
||||
subRouter.Handle("/delete-short-url/{shortUrl}", handler.DeleteShortUrl(store)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/{shortUrl}", handler.HandleShortUrlRedirect).Methods(http.MethodGet)
|
||||
|
||||
srv := &http.Server{
|
||||
@ -62,7 +127,8 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
store.CloseStoreRedisConn()
|
||||
service.CloseStoreRedisConn()
|
||||
conn.Close()
|
||||
|
||||
srv.Shutdown(ctx)
|
||||
log.Println("shutting down")
|
||||
|
||||
41
pkg/cookie/cookie.go
Normal file
41
pkg/cookie/cookie.go
Normal file
@ -0,0 +1,41 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorizeName = "authorize"
|
||||
)
|
||||
|
||||
func NewCookie(name, value string, expired time.Time) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
Secure: false, // true->只能https站点操作
|
||||
HttpOnly: true, // true->js不能捕获
|
||||
Expires: expired,
|
||||
}
|
||||
}
|
||||
|
||||
func SetCookie(w http.ResponseWriter, name, value string, expired time.Time) {
|
||||
cookie := NewCookie(name, value, expired)
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
|
||||
func ReadCookie(r *http.Request, name string) (string, error) {
|
||||
cookie, err := r.Cookie(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cookie.Value, nil
|
||||
}
|
||||
|
||||
func DeleteCookie(w http.ResponseWriter, name string) {
|
||||
cookie := NewCookie(name, "", time.Now().Add(time.Duration(-10)*time.Second))
|
||||
cookie.MaxAge = -1
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
33
pkg/logger/logger.go
Normal file
33
pkg/logger/logger.go
Normal file
@ -0,0 +1,33 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/natefinch/lumberjack"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
var Logger *zap.SugaredLogger
|
||||
|
||||
func NewLogger() {
|
||||
core := zapcore.NewCore(getEncoder(), getLogWriter(), zapcore.DebugLevel)
|
||||
logger := zap.New(core, zap.AddCaller())
|
||||
Logger = logger.Sugar()
|
||||
}
|
||||
|
||||
func getEncoder() zapcore.Encoder {
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
|
||||
encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder
|
||||
return zapcore.NewConsoleEncoder(encoderConfig)
|
||||
}
|
||||
|
||||
func getLogWriter() zapcore.WriteSyncer {
|
||||
lumberJackLogger := &lumberjack.Logger{
|
||||
Filename: "./log/run.log", // 日志文件的位置
|
||||
MaxSize: 10, // 在进行切割之前,日志文件的最大大小(以MB为单位)
|
||||
MaxBackups: 100, // 保留旧文件的最大个数
|
||||
MaxAge: 365, // 保留旧文件的最大天数
|
||||
Compress: false, // 是否压缩/归档旧文件
|
||||
}
|
||||
return zapcore.AddSync(lumberJackLogger)
|
||||
}
|
||||
79
pkg/password/password.go
Normal file
79
pkg/password/password.go
Normal file
@ -0,0 +1,79 @@
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
// ******************** scrypt ********************
|
||||
|
||||
// ScryptHashPassword scrypt 加密
|
||||
// password 原始密码
|
||||
func ScryptHashPassword(password string) (string, error) {
|
||||
// example for making salt - https://play.golang.org/p/_Aw6WeWC42I
|
||||
salt := make([]byte, 32)
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// using recommended cost parameters from - https://godoc.org/golang.org/x/crypto/scrypt
|
||||
shash, err := scrypt.Key([]byte(password), salt, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// return hex-encoded string with salt appended to password
|
||||
hashedPW := fmt.Sprintf("%s.%s", hex.EncodeToString(shash), hex.EncodeToString(salt))
|
||||
|
||||
return hashedPW, nil
|
||||
}
|
||||
|
||||
// ScryptComparePassword 判断密码是否正确
|
||||
// storedPassword 加密密码
|
||||
// suppliedPassword 原始密码
|
||||
func ScryptComparePassword(storedPassword string, suppliedPassword string) error {
|
||||
pwsalt := strings.Split(storedPassword, ".")
|
||||
|
||||
// check supplied password salted with hash
|
||||
salt, err := hex.DecodeString(pwsalt[1])
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to verify user password")
|
||||
}
|
||||
|
||||
shash, err := scrypt.Key([]byte(suppliedPassword), salt, 32768, 8, 1, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hex.EncodeToString(shash) == pwsalt[0] {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("password error")
|
||||
}
|
||||
|
||||
// ******************** bcrypt ********************
|
||||
|
||||
// BcryptHashPassword bcrypt 加密
|
||||
// password 原始密码
|
||||
func BcryptHashPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
// BcryptComparePassword 判断密码是否正确
|
||||
// hashedPassword 加密密码
|
||||
// password 原始密码
|
||||
func BcryptComparePassword(hashedPassword string, password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package store
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -52,3 +52,7 @@ func RetrieveInitialUrl(shortUrl string) (string, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DeleteShortUrl(shortUrl string) error {
|
||||
return storeService.redisClient.Set(ctx, shortUrl, "", time.Second).Err()
|
||||
}
|
||||
@ -15,7 +15,8 @@ func sha256Of(input string) []byte {
|
||||
}
|
||||
|
||||
func base58Encoded(bytes []byte) (string, error) {
|
||||
encoded, err := base58.BitcoinEncoding.Encode(bytes)
|
||||
encoding := base58.BitcoinEncoding
|
||||
encoded, err := encoding.Encode(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -31,5 +32,5 @@ func GenerateShortLink(originUrl string, userId string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return result[:8], nil
|
||||
}
|
||||
|
||||
@ -12,15 +12,15 @@ func TestGenerateShortLink(t *testing.T) {
|
||||
link1 := "https://www.baidu.com/"
|
||||
short1, err := GenerateShortLink(link1, userId)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, short1, "egtq236P5f3")
|
||||
assert.Equal(t, short1, "egtq236P")
|
||||
|
||||
link2 := "https://www.163.com/"
|
||||
short2, err := GenerateShortLink(link2, userId)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, short2, "DiCqg9YpV89")
|
||||
assert.Equal(t, short2, "DiCqg9Yp")
|
||||
|
||||
link3 := "https://www.qq.com/"
|
||||
short3, err := GenerateShortLink(link3, userId)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, short3, "4QhQ62cZem1")
|
||||
assert.Equal(t, short3, "4QhQ62cZ")
|
||||
}
|
||||
|
||||
17
sqlc.yaml
Normal file
17
sqlc.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "./db/query/"
|
||||
schema: "./db/schema/"
|
||||
|
||||
gen:
|
||||
go:
|
||||
package: "db"
|
||||
out: "./db/"
|
||||
emit_json_tags: true
|
||||
emit_prepared_queries: false
|
||||
emit_interface: true
|
||||
emit_exact_table_names: false
|
||||
emit_empty_slices: true
|
||||
emit_result_struct_pointers: true
|
||||
emit_params_struct_pointers: true
|
||||
7
web/static/css/bootstrap.min.css
vendored
Normal file
7
web/static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/static/css/bootstrap.min.css.map
Normal file
1
web/static/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
100
web/static/css/index.css
Normal file
100
web/static/css/index.css
Normal file
@ -0,0 +1,100 @@
|
||||
@charset "utf-8";
|
||||
|
||||
html,
|
||||
body {
|
||||
/* 禁用空格键的滚动 */
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.justify-content {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.align-items {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-wh {
|
||||
padding: 0.2rem 1.6rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, .12);
|
||||
}
|
||||
|
||||
.navbar-brand-fs {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
fill: currentColor;
|
||||
/* color: #f44336; */
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.oauth {
|
||||
align-items: center;
|
||||
list-style-type: none;
|
||||
height: 40px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.oauth li {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.main {
|
||||
max-width: 1280px;
|
||||
width: 100%;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.main .title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.main .title a {
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
width: 100%;
|
||||
background-color: rgb(229, 246, 253);
|
||||
padding: 1rem;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tip-box .tip-icon {
|
||||
width: 24px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.tip-box .tip-icon svg {
|
||||
fill: currentColor;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.tip-box .tip-info p {
|
||||
letter-spacing: 0.00938em;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: rgb(1, 67, 97);
|
||||
margin-bottom: 0;
|
||||
line-height: 24px;
|
||||
}
|
||||
7
web/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
web/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/static/js/bootstrap.bundle.min.js.map
Normal file
1
web/static/js/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
7
web/static/js/bootstrap.min.js
vendored
Normal file
7
web/static/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/static/js/bootstrap.min.js.map
Normal file
1
web/static/js/bootstrap.min.js.map
Normal file
File diff suppressed because one or more lines are too long
2
web/static/js/jquery.min.js
vendored
Normal file
2
web/static/js/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
20
web/template/base/footer.html.tmpl
Normal file
20
web/template/base/footer.html.tmpl
Normal file
@ -0,0 +1,20 @@
|
||||
<!-- <!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="/statics/css/bootstrap.min.css" />
|
||||
<title>HLS流媒体</title>
|
||||
{{block "css" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper"> -->
|
||||
{{define "footer"}}
|
||||
</div>
|
||||
<script src="/static/js/jquery.min.js"></script>
|
||||
<script src="/static/js/bootstrap.bundle.min.js"></script>
|
||||
{{block "js" .}}{{end}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
44
web/template/base/header.html.tmpl
Normal file
44
web/template/base/header.html.tmpl
Normal file
@ -0,0 +1,44 @@
|
||||
{{define "header"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="shortcut icon" href="/statics/favicon.ico" type="image/x-icon" />
|
||||
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/static/css/index.css" />
|
||||
{{block "css" .}}{{end}}
|
||||
<title>URL段地址服务</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<nav class="navbar navbar-light bg-light navbar-wh">
|
||||
<a class="navbar-brand navbar-brand-fs" href="/">
|
||||
URL段地址服务
|
||||
</a>
|
||||
<ul class="flex oauth">
|
||||
{{if currentUser}}
|
||||
<li style="font-size: 12px;">
|
||||
欢迎您: {{ currentUser.Name }}
|
||||
</li>
|
||||
<li style="font-size: 12px;">
|
||||
<a href="/logout" class="btn btn-primary btn-sm">退出</a>
|
||||
</li>
|
||||
{{else}}
|
||||
<li>
|
||||
<a href="/login" class="btn btn-outline-primary btn-sm">登录</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/register" class="btn btn-primary btn-sm">注册</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
{{end}}
|
||||
<!-- </div>
|
||||
<script src="/statics/js/jquery.slim.min.js"></script>
|
||||
<script src="/statics/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html> -->
|
||||
83
web/template/home.html.tmpl
Normal file
83
web/template/home.html.tmpl
Normal file
@ -0,0 +1,83 @@
|
||||
{{template "header" .}}
|
||||
<div class="container-fluid flex justify-content">
|
||||
<div class="main">
|
||||
<h3 style="margin-top: 20px;margin-bottom: 10px;">短地址列表 <a class="btn btn-primary"
|
||||
href="/create-short-url">添加</a></h3>
|
||||
<table class="my_table" style="display: block;">
|
||||
<tr>
|
||||
<th width="600px">原地址</th>
|
||||
<th width="320px">短地址</th>
|
||||
<th width="80px">是否有效</th>
|
||||
<th width="80px">删除</th>
|
||||
</tr>
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td width="600px">{{.OriginUrl}}</td>
|
||||
<td width="320px"><a target="_blank" href="{{genShortUrl .ShortUrl}}">{{genShortUrl .ShortUrl}}</a></td>
|
||||
<td width="80px">
|
||||
{{if eq .Status 0}}
|
||||
<code>YES</code>
|
||||
{{else}}
|
||||
<code>NO</code>
|
||||
{{end}}
|
||||
</td>
|
||||
<td width="80px">
|
||||
{{if eq .Status 0}}
|
||||
<button data-short-url="{{.ShortUrl}}" class="btn btn-danger deleteShortUrl">删除</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{ csrfField }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{define "css"}}
|
||||
<style>
|
||||
.my_table {
|
||||
display: block;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.my_table tr {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
border: 1px solid #eee;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.my_table tr td {
|
||||
display: inline-block;
|
||||
word-wrap: break-word;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
{{define "js"}}
|
||||
<script>
|
||||
$('.deleteShortUrl').click(function () {
|
||||
let csrfToken = $('input[name="csrf_token"]').val()
|
||||
let u = $(this).attr('data-short-url')
|
||||
$.ajax({
|
||||
url: '/delete-short-url/' + u,
|
||||
type: 'POST',
|
||||
cache: false,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken
|
||||
},
|
||||
success: function (res) {
|
||||
if (res.success) {
|
||||
alert('删除成功');
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
{{template "footer" .}}
|
||||
25
web/template/short_url/create.html.tmpl
Normal file
25
web/template/short_url/create.html.tmpl
Normal file
@ -0,0 +1,25 @@
|
||||
{{template "header" .}}
|
||||
<div class="container">
|
||||
<div class="flex flex-column align-items row py-md-5 mt-md-5">
|
||||
<h1>创建短路径</h1>
|
||||
<div class="col-sm-4 py-md-5">
|
||||
<form action="/create-short-url" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">原路径</span>
|
||||
</div>
|
||||
<input type="text" name="long_url" class="form-control" required id="long_url">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">创建</button>
|
||||
</form>
|
||||
{{if .}}
|
||||
<div class="py-md-5" style="color: #f44336;">
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
42
web/template/user/login.html.tmpl
Normal file
42
web/template/user/login.html.tmpl
Normal file
@ -0,0 +1,42 @@
|
||||
{{template "header" .}}
|
||||
<div class="container">
|
||||
<div class="flex flex-column align-items row py-md-5 mt-md-5">
|
||||
<h1>登录</h1>
|
||||
<div class="col-sm-4 py-md-5">
|
||||
<form action="/login" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">邮箱</span>
|
||||
</div>
|
||||
<input type="email" name="email" class="form-control" required id="email" value="{{.Email}}"
|
||||
aria-describedby="emailValid">
|
||||
</div>
|
||||
{{if .EmailMsg}}
|
||||
<small id="emailValid" style="color: #f44336;" class="form-text">{{.EmailMsg}}</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">密码</span>
|
||||
</div>
|
||||
<input type="password" name="password" class="form-control" required id="password"
|
||||
value="{{.Password}}" aria-describedby="passwordValid">
|
||||
</div>
|
||||
{{if .PasswordMsg}}
|
||||
<small id="passwordValid" style="color: #f44336;" class="form-text">{{.PasswordMsg}}</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">提交</button>
|
||||
</form>
|
||||
{{if .Summary}}
|
||||
<div class="py-md-5" style="color: #f44336;">
|
||||
{{.Summary}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
54
web/template/user/register.html.tmpl
Normal file
54
web/template/user/register.html.tmpl
Normal file
@ -0,0 +1,54 @@
|
||||
{{template "header" .}}
|
||||
<div class="container">
|
||||
<div class="flex flex-column align-items row py-md-5 mt-md-5">
|
||||
<h1>注册</h1>
|
||||
<div class="col-sm-4 py-md-5">
|
||||
<form action="/register" method="post">
|
||||
{{ csrfField }}
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">邮箱</span>
|
||||
</div>
|
||||
<input type="email" name="email" class="form-control" required id="email" value="{{.Email}}"
|
||||
aria-describedby="emailValid">
|
||||
</div>
|
||||
{{if .EmailMsg}}
|
||||
<small id="emailValid" style="color: #f44336;" class="form-text">{{.EmailMsg}}</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">名称</span>
|
||||
</div>
|
||||
<input type="text" name="username" class="form-control" required id="username"
|
||||
value="{{.Username}}" aria-describedby="usernameValid">
|
||||
</div>
|
||||
{{if .UsernameMsg}}
|
||||
<small id="usernameValid" style="color: #f44336;" class="form-text">{{.UsernameMsg}}</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">密码</span>
|
||||
</div>
|
||||
<input type="password" name="password" class="form-control" required id="password"
|
||||
value="{{.Password}}" aria-describedby="passwordValid">
|
||||
</div>
|
||||
{{if .PasswordMsg}}
|
||||
<small id="passwordValid" style="color: #f44336;" class="form-text">{{.PasswordMsg}}</small>
|
||||
{{end}}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">提交</button>
|
||||
</form>
|
||||
{{if .Summary}}
|
||||
<div class="py-md-5" style="color: #f44336;">
|
||||
{{.Summary}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
Loading…
x
Reference in New Issue
Block a user