commit 49b24e5773524bb4f89d8623666d5200177f262b Author: sam Date: Fri Aug 25 02:25:38 2023 +0200 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f873998 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# the storage driver used +# - local: stores files on a local hard drive +# - s3: uses S3-compatible storage (not yet implemented) +STORAGE=local + +# for local storage, the path used +# if the path starts with a /, it's treated as an absolute path +# if not, it's treated as a path relative to the working directory +# defaults to "uploads" +LOCAL_PATH=/var/filer/uploads + +# the port used by the web server +# the server will not run if this is empty +PORT=8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18114c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.db +uploads/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/cmd/users/users.go b/cmd/users/users.go new file mode 100644 index 0000000..73d4a51 --- /dev/null +++ b/cmd/users/users.go @@ -0,0 +1,49 @@ +package users + +import ( + "fmt" + + "codeberg.org/u1f320/filer/db" + "github.com/olekukonko/tablewriter" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "users", + Usage: "Manage user accounts", + Action: run, +} + +func run(c *cli.Context) error { + db, err := db.New() + if err != nil { + return err + } + + users, err := db.GetUsers(c.Context) + if err != nil { + return err + } + + table := tablewriter.NewWriter(c.App.Writer) + table.SetHeader([]string{"ID", "Username", "Admin?"}) + table.SetBorder(false) + + for _, u := range users { + table.Append([]string{ + fmt.Sprint(u.ID), + u.Username, + yesNo(u.IsAdmin), + }) + } + + table.Render() + return nil +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} diff --git a/cmd/web/web.go b/cmd/web/web.go new file mode 100644 index 0000000..91d0cda --- /dev/null +++ b/cmd/web/web.go @@ -0,0 +1,24 @@ +package web + +import ( + "codeberg.org/u1f320/filer/db" + "codeberg.org/u1f320/filer/web" + "github.com/urfave/cli/v2" +) + +var Command = &cli.Command{ + Name: "web", + Usage: "Serve the web server", + Action: run, +} + +func run(c *cli.Context) error { + db, err := db.New() + if err != nil { + return err + } + + app := web.New(db) + app.Run(c.Context) + return nil +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..28add3d --- /dev/null +++ b/db/db.go @@ -0,0 +1,78 @@ +package db + +import ( + "database/sql" + "embed" + "os" + + "codeberg.org/u1f320/filer/db/queries" + "codeberg.org/u1f320/filer/store" + "codeberg.org/u1f320/filer/store/local" + "emperror.dev/errors" + "github.com/joho/godotenv" + "github.com/rs/zerolog/log" + migrate "github.com/rubenv/sql-migrate" + + // sqlite driver + _ "modernc.org/sqlite" +) + +func init() { + godotenv.Load() +} + +const defaultDBName = "filer.db?_pragma=foreign_keys(1)" + +type DB struct { + *queries.Queries + + db *sql.DB + Store store.Store +} + +func New() (*DB, error) { + sqldb, err := sql.Open("sqlite", defaultDBName) + if err != nil { + return nil, errors.Wrap(err, "creating database") + } + + err = migrateDB(sqldb, defaultDBName) + if err != nil { + return nil, errors.Wrap(err, "migrating database") + } + + db := &DB{ + Queries: queries.New(sqldb), + db: sqldb, + } + + switch os.Getenv("STORAGE") { + case local.StoreKey: + db.Store, err = local.New(os.Getenv("LOCAL_PATH")) + default: + err = store.ErrInvalidStoreKey + } + if err != nil { + return nil, errors.Wrap(err, "creating store") + } + + return db, nil +} + +//go:embed migrations +var migrateFS embed.FS + +func migrateDB(db *sql.DB, dbName string) error { + migrations := &migrate.EmbedFileSystemMigrationSource{ + FileSystem: migrateFS, + Root: "migrations", + } + + n, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up) + if err != nil { + return errors.Wrap(err, "executing migrations") + } + + log.Info().Int("count", n).Msg("Performed migrations!") + return nil +} diff --git a/db/migrations/1692914919-init.sql b/db/migrations/1692914919-init.sql new file mode 100644 index 0000000..04d8f49 --- /dev/null +++ b/db/migrations/1692914919-init.sql @@ -0,0 +1,33 @@ +-- +migrate Up + +create table users ( + id integer primary key, + username text not null unique, + password text not null, + is_admin boolean not null default false +); + +create table tokens ( + id integer primary key, + user_id integer not null references users (id) on delete cascade, + token text not null unique +); + +create table files ( + id text primary key, -- uuid + user_id integer not null references users (id) on delete cascade, + filename text not null, + content_type text not null, + hash text not null, + size integer not null, + created_at integer not null, + expires integer, + + unique(filename, hash) +); + +-- +migrate Down + +drop table files; +drop table tokens; +drop table users; diff --git a/db/queries.sql b/db/queries.sql new file mode 100644 index 0000000..0f0fa15 --- /dev/null +++ b/db/queries.sql @@ -0,0 +1,47 @@ +-- name: CreateUser :one +insert into users (username, password) values (@username, @password) +returning *; + +-- name: GetUser :one +select * from users where username = @username; + +-- name: GetUsers :many +select * from users order by id; + +-- name: UpdateUser :one +update users set +username = @username, password = @password, is_admin = @is_admin +where id = @id +returning *; + +-- name: DeleteUser :exec +delete from users where id = @id; + +-- name: GetUserByToken :one +select u.* from users u join tokens t on u.id = t.user_id +where t.token = @token; + +-- name: CreateToken :one +insert into tokens (user_id, token) values (@user_id, @token) +returning *; + +-- name: DeleteToken :exec +delete from tokens where id = @id; + +-- name: CreateFile :one +insert into files +(id, user_id, filename, content_type, hash, size, created_at, expires) +values (@id, @user_id, @filename, @content_type, @hash, @size, unixepoch(), @expires) +returning *; + +-- name: GetFileByID :one +select * from files where id = @id; + +-- name: GetFileByName :one +select * from files where filename = @filename and hash = @hash; + +-- name: GetExpiredFiles :many +select * from files where expires < unixepoch(); + +-- name: DeleteFile :exec +delete from files where id = @id; diff --git a/db/queries/db.go b/db/queries/db.go new file mode 100644 index 0000000..99526a5 --- /dev/null +++ b/db/queries/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.20.0 + +package queries + +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, + } +} diff --git a/db/queries/models.go b/db/queries/models.go new file mode 100644 index 0000000..4f88b08 --- /dev/null +++ b/db/queries/models.go @@ -0,0 +1,35 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.20.0 + +package queries + +import ( + "database/sql" + + "github.com/google/uuid" +) + +type File struct { + ID uuid.UUID + UserID int64 + Filename string + ContentType string + Hash string + Size int64 + CreatedAt int64 + Expires sql.NullInt64 +} + +type Token struct { + ID int64 + UserID int64 + Token string +} + +type User struct { + ID int64 + Username string + Password string + IsAdmin bool +} diff --git a/db/queries/queries.sql.go b/db/queries/queries.sql.go new file mode 100644 index 0000000..bd62445 --- /dev/null +++ b/db/queries/queries.sql.go @@ -0,0 +1,297 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.20.0 +// source: queries.sql + +package queries + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const createFile = `-- name: CreateFile :one +insert into files +(id, user_id, filename, content_type, hash, size, created_at, expires) +values (?1, ?2, ?3, ?4, ?5, ?6, unixepoch(), ?7) +returning id, user_id, filename, content_type, hash, size, created_at, expires +` + +type CreateFileParams struct { + ID uuid.UUID + UserID int64 + Filename string + ContentType string + Hash string + Size int64 + Expires sql.NullInt64 +} + +func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (File, error) { + row := q.db.QueryRowContext(ctx, createFile, + arg.ID, + arg.UserID, + arg.Filename, + arg.ContentType, + arg.Hash, + arg.Size, + arg.Expires, + ) + var i File + err := row.Scan( + &i.ID, + &i.UserID, + &i.Filename, + &i.ContentType, + &i.Hash, + &i.Size, + &i.CreatedAt, + &i.Expires, + ) + return i, err +} + +const createToken = `-- name: CreateToken :one +insert into tokens (user_id, token) values (?1, ?2) +returning id, user_id, token +` + +type CreateTokenParams struct { + UserID int64 + Token string +} + +func (q *Queries) CreateToken(ctx context.Context, arg CreateTokenParams) (Token, error) { + row := q.db.QueryRowContext(ctx, createToken, arg.UserID, arg.Token) + var i Token + err := row.Scan(&i.ID, &i.UserID, &i.Token) + return i, err +} + +const createUser = `-- name: CreateUser :one +insert into users (username, password) values (?1, ?2) +returning id, username, password, is_admin +` + +type CreateUserParams struct { + Username string + Password string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, arg.Username, arg.Password) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.IsAdmin, + ) + return i, err +} + +const deleteFile = `-- name: DeleteFile :exec +delete from files where id = ?1 +` + +func (q *Queries) DeleteFile(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteFile, id) + return err +} + +const deleteToken = `-- name: DeleteToken :exec +delete from tokens where id = ?1 +` + +func (q *Queries) DeleteToken(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteToken, id) + return err +} + +const deleteUser = `-- name: DeleteUser :exec +delete from users where id = ?1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteUser, id) + return err +} + +const getExpiredFiles = `-- name: GetExpiredFiles :many +select id, user_id, filename, content_type, hash, size, created_at, expires from files where expires < unixepoch() +` + +func (q *Queries) GetExpiredFiles(ctx context.Context) ([]File, error) { + rows, err := q.db.QueryContext(ctx, getExpiredFiles) + if err != nil { + return nil, err + } + defer rows.Close() + var items []File + for rows.Next() { + var i File + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Filename, + &i.ContentType, + &i.Hash, + &i.Size, + &i.CreatedAt, + &i.Expires, + ); 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 getFileByID = `-- name: GetFileByID :one +select id, user_id, filename, content_type, hash, size, created_at, expires from files where id = ?1 +` + +func (q *Queries) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByID, id) + var i File + err := row.Scan( + &i.ID, + &i.UserID, + &i.Filename, + &i.ContentType, + &i.Hash, + &i.Size, + &i.CreatedAt, + &i.Expires, + ) + return i, err +} + +const getFileByName = `-- name: GetFileByName :one +select id, user_id, filename, content_type, hash, size, created_at, expires from files where filename = ?1 and hash = ?2 +` + +type GetFileByNameParams struct { + Filename string + Hash string +} + +func (q *Queries) GetFileByName(ctx context.Context, arg GetFileByNameParams) (File, error) { + row := q.db.QueryRowContext(ctx, getFileByName, arg.Filename, arg.Hash) + var i File + err := row.Scan( + &i.ID, + &i.UserID, + &i.Filename, + &i.ContentType, + &i.Hash, + &i.Size, + &i.CreatedAt, + &i.Expires, + ) + return i, err +} + +const getUser = `-- name: GetUser :one +select id, username, password, is_admin from users where username = ?1 +` + +func (q *Queries) GetUser(ctx context.Context, username string) (User, error) { + row := q.db.QueryRowContext(ctx, getUser, username) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.IsAdmin, + ) + return i, err +} + +const getUserByToken = `-- name: GetUserByToken :one +select u.id, u.username, u.password, u.is_admin from users u join tokens t on u.id = t.user_id +where t.token = ?1 +` + +func (q *Queries) GetUserByToken(ctx context.Context, token string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByToken, token) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.IsAdmin, + ) + return i, err +} + +const getUsers = `-- name: GetUsers :many +select id, username, password, is_admin from users order by id +` + +func (q *Queries) GetUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.IsAdmin, + ); 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 +username = ?1, password = ?2, is_admin = ?3 +where id = ?4 +returning id, username, password, is_admin +` + +type UpdateUserParams struct { + Username string + Password string + IsAdmin bool + ID int64 +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUser, + arg.Username, + arg.Password, + arg.IsAdmin, + arg.ID, + ) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.IsAdmin, + ) + return i, err +} diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..1452742 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,25 @@ +create table users ( + id integer primary key, + username text not null unique, + password text not null, + is_admin boolean not null default false +); + +create table tokens ( + id integer primary key, + user_id integer not null references users (id) on delete cascade, + token text not null unique +); + +create table files ( + id text primary key, -- uuid + user_id integer not null references users (id) on delete cascade, + filename text not null, + content_type text not null, + hash text not null, + size integer not null, + created_at integer not null, + expires integer, + + unique(filename, hash) +); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..460c13c --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module codeberg.org/u1f320/filer + +go 1.20 + +require ( + emperror.dev/errors v0.8.1 + github.com/go-chi/chi/v5 v5.0.10 + github.com/go-chi/cors v1.2.1 + github.com/google/uuid v1.3.0 + github.com/joho/godotenv v1.5.1 + github.com/olekukonko/tablewriter v0.0.5 + github.com/rs/zerolog v1.30.0 + github.com/rubenv/sql-migrate v1.5.2 + github.com/urfave/cli/v2 v2.25.7 + modernc.org/sqlite v1.18.1 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sirupsen/logrus v1.9.2 // indirect + github.com/stretchr/testify v1.8.1 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/term v0.8.0 // indirect + golang.org/x/tools v0.9.1 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.36.3 // indirect + modernc.org/ccgo/v3 v3.16.9 // indirect + modernc.org/libc v1.17.1 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.2.1 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.1.3 // indirect + modernc.org/token v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..93b5196 --- /dev/null +++ b/go.sum @@ -0,0 +1,161 @@ +emperror.dev/errors v0.8.1 h1:UavXZ5cSX/4u9iyvH6aDcuGkVjeexUGJ7Ij7G4VfQT0= +emperror.dev/errors v0.8.1/go.mod h1:YcRvLPh626Ubn2xqtoprejnA5nFha+TJ+2vew48kWuE= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= +github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= +github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0= +github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= +github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c= +github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w= +github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= +github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.2 h1:oxx1eChJGI6Uks2ZC4W1zpLlVgqB8ner4EuQwV4Ik1Y= +github.com/sirupsen/logrus v1.9.2/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= +github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo= +golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/cc/v3 v3.36.3 h1:uISP3F66UlixxWEcKuIWERa4TwrZENHSL8tWxZz8bHg= +modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI= +modernc.org/ccgo/v3 v3.16.9 h1:AXquSwg7GuMk11pIdw7fmO1Y/ybgazVkMhsZWCV0mHM= +modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0= +modernc.org/libc v1.17.1 h1:Q8/Cpi36V/QBfuQaFVeisEBs3WqoGAJprZzmf7TfEYI= +modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s= +modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw= +modernc.org/memory v1.2.1 h1:dkRh86wgmq/bJu2cAS2oqBCz/KsMZU7TUM4CibQ7eBs= +modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.18.1 h1:ko32eKt3jf7eqIkCgPAeHMBXw3riNSLhl2f3loEF7o8= +modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4= +modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= +modernc.org/tcl v1.13.1 h1:npxzTwFTZYM8ghWicVIX1cRWzj7Nd8i6AqqX2p+IYao= +modernc.org/token v1.0.0 h1:a0jaWiNMDhDUtqOj09wvjWWAqd3q7WpBulmL9H2egsk= +modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.5.1 h1:RTNHdsrOpeoSeOF4FbzTo8gBYByaJ5xT7NgZ9ZqRiJM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..82a42ef --- /dev/null +++ b/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + + "codeberg.org/u1f320/filer/cmd/users" + "codeberg.org/u1f320/filer/cmd/web" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/rs/zerolog/pkgerrors" + "github.com/urfave/cli/v2" +) + +var app = &cli.App{ + HelpName: "filer", + Usage: "Simple Go file server", + Commands: []*cli.Command{ + web.Command, + users.Command, + }, +} + +func main() { + // set the logger to output to console format + // while performance is nice, the logger is not the place we'll ever get bottlenecked + // and the console output is friendlier + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "[15:04:05]"}) + zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack + + if err := app.Run(os.Args); err != nil { + log.Error().Stack().Err(err).Msg("running app") + } +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..3c596a1 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,12 @@ +version: 2 +sql: + - engine: "sqlite" + schema: "db/schema.sql" + queries: "db/queries.sql" + gen: + go: + package: "queries" + out: "db/queries" + overrides: + - column: "files.id" + go_type: "github.com/google/uuid.UUID" diff --git a/store/local/local.go b/store/local/local.go new file mode 100644 index 0000000..dec7e30 --- /dev/null +++ b/store/local/local.go @@ -0,0 +1,126 @@ +package local + +import ( + "bytes" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "codeberg.org/u1f320/filer/store" + "emperror.dev/errors" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +const ( + StoreKey = "local" + ErrNotDirectory = errors.Sentinel("not a directory") + ErrAlreadyExists = errors.Sentinel("file already exists") + + defaultPath = "uploads" +) + +type localStore struct { + path string +} + +var _ store.Store = (*localStore)(nil) + +// New creates a new local store. +// It creates the directory if it doesn't exist, +func New(path string) (store.Store, error) { + if path == "" { + path = defaultPath + } + + if !strings.HasPrefix(path, "/") { + wd, err := os.Getwd() + if err != nil { + return nil, errors.Wrap(err, "getting working directory") + } + + path = filepath.Join(wd, path) + } + + log.Info().Str("path", path).Msg("Using local storage") + + fi, err := os.Stat(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, errors.Wrap(err, "calling Stat on path") + } + + err = os.MkdirAll(path, 0o744) + if err != nil { + return nil, errors.Wrap(err, "creating uploads directory") + } + } else if !fi.IsDir() { + return nil, ErrNotDirectory + } + + return &localStore{ + path: path, + }, nil +} + +func (l *localStore) WriteFile(id uuid.UUID, data io.Reader, contentType string) (err error) { + path := filepath.Join(l.path, id.String()) + + _, err = os.Stat(path) + if err == nil { + return ErrAlreadyExists + } + + file, err := os.Create(path) + if err != nil { + return errors.Wrap(err, "creating file") + } + defer func() { + err = errors.Append(err, file.Close()) + }() + + _, err = io.Copy(file, data) + if err != nil { + return errors.Wrap(err, "writing data to file") + } + + return nil +} + +func (l *localStore) GetFile(id uuid.UUID) (r io.Reader, err error) { + path := filepath.Join(l.path, id.String()) + + file, err := os.Open(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, store.ErrNotExist + } + } + defer func() { + err = errors.Append(err, file.Close()) + }() + + b := new(bytes.Buffer) + _, err = io.Copy(b, file) + if err != nil { + return nil, errors.Wrap(err, "copying file to buffer") + } + + return b, nil +} + +func (l *localStore) DeleteFile(id uuid.UUID) error { + path := filepath.Join(l.path, id.String()) + + err := os.Remove(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil // a file already not existing is not an error + } + + return errors.Wrap(err, "removing file") + } + return nil +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 0000000..92f2363 --- /dev/null +++ b/store/store.go @@ -0,0 +1,19 @@ +package store + +import ( + "io" + + "emperror.dev/errors" + "github.com/google/uuid" +) + +const ( + ErrNotExist = errors.Sentinel("file does not exist") + ErrInvalidStoreKey = errors.Sentinel("invalid $STORAGE key") +) + +type Store interface { + WriteFile(id uuid.UUID, data io.Reader, contentType string) error + GetFile(id uuid.UUID) (io.Reader, error) + DeleteFile(id uuid.UUID) error +} diff --git a/web/logger.go b/web/logger.go new file mode 100644 index 0000000..972cfa1 --- /dev/null +++ b/web/logger.go @@ -0,0 +1,48 @@ +package web + +import ( + "net/http" + "runtime/debug" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/rs/zerolog/log" +) + +func requestLogger(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + t1 := time.Now() + defer func() { + t2 := time.Now() + + // Recover and record stack traces in case of a panic + if rec := recover(); rec != nil { + log.Error(). + Str("type", "error"). + Timestamp(). + Interface("recover_info", rec). + Bytes("debug_stack", debug.Stack()). + Msg("Handler panicked") + http.Error(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + // log end request + log.Info(). + Timestamp(). + Str("remote_ip", r.RemoteAddr). + Str("url", r.URL.Path). + Str("proto", r.Proto). + Str("method", r.Method). + Int("status", ww.Status()). + Dur("elapsed", t2.Sub(t1)). + Int64("bytes_in", r.ContentLength). + Int("bytes_out", ww.BytesWritten()). + Msg(r.Method + " " + r.URL.Path) + }() + + next.ServeHTTP(ww, r) + } + return http.HandlerFunc(fn) +} diff --git a/web/show_file.go b/web/show_file.go new file mode 100644 index 0000000..8f16970 --- /dev/null +++ b/web/show_file.go @@ -0,0 +1,49 @@ +package web + +import ( + "database/sql" + "io" + "net/http" + "strconv" + + "codeberg.org/u1f320/filer/db/queries" + "emperror.dev/errors" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" +) + +func (app *App) showFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + hash := chi.URLParam(r, "hash") + filename := chi.URLParam(r, "filename") + + file, err := app.db.GetFileByName(ctx, queries.GetFileByNameParams{ + Filename: filename, + Hash: hash, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + log.Err(err).Str("hash", hash).Str("name", filename).Msg("getting file from database") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + data, err := app.db.Store.GetFile(file.ID) + if err != nil { + log.Err(err).Str("hash", hash).Str("name", filename).Msg("getting file data") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", file.ContentType) + w.Header().Add("Content-Length", strconv.FormatInt(file.Size, 10)) + _, err = io.Copy(w, data) + if err != nil { + log.Err(err).Str("hash", hash).Str("name", filename).Msg("writing file data") + return + } +} diff --git a/web/upload_file.go b/web/upload_file.go new file mode 100644 index 0000000..0ef7c1f --- /dev/null +++ b/web/upload_file.go @@ -0,0 +1,103 @@ +package web + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "io" + "net/http" + + "codeberg.org/u1f320/filer/db/queries" + "emperror.dev/errors" + "github.com/google/uuid" + "github.com/rs/zerolog/log" +) + +// 500 megabytes +const maxMemory = 500_000_000 + +func (app *App) uploadFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + token := r.Header.Get("Authorization") + if token == "" { + http.Error(w, "No token provided", http.StatusUnauthorized) + return + } + + u, err := app.db.GetUserByToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, "Invalid token provided", http.StatusForbidden) + return + } + } + + err = r.ParseMultipartForm(maxMemory) + if err != nil { + log.Err(err).Msg("parsing multipart form data") + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + file, fileHeader, err := r.FormFile("file") + if err != nil { + log.Err(err).Msg("getting file from multipart form") + http.Error(w, "Bad request", http.StatusBadRequest) + } + + buffer := new(bytes.Buffer) + _, err = io.Copy(buffer, file) + if err != nil { + log.Err(err).Msg("copying file to temporary buffer") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + contentType := http.DetectContentType(buffer.Bytes()) + hash, err := getSha256Hash(buffer.Bytes()) + if err != nil { + log.Err(err).Msg("generating file hash") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + dbfile, err := app.db.CreateFile(ctx, queries.CreateFileParams{ + ID: uuid.New(), + UserID: u.ID, + Filename: fileHeader.Filename, + ContentType: contentType, + Hash: hash, + Size: int64(buffer.Len()), + }) + if err != nil { + log.Err(err).Msg("creating file in database") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + err = app.db.Store.WriteFile(dbfile.ID, buffer, contentType) + if err != nil { + log.Err(err).Msg("creating file in store") + err = app.db.DeleteFile(ctx, dbfile.ID) + if err != nil { + log.Err(err).Msg("deleting file from database") + } + + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("/%s/%s", hash, fileHeader.Filename))) +} + +func getSha256Hash(b []byte) (string, error) { + hasher := sha256.New() + _, err := hasher.Write(b) + if err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..f8e0521 --- /dev/null +++ b/web/web.go @@ -0,0 +1,63 @@ +package web + +import ( + "context" + "net/http" + "os" + "regexp" + + "codeberg.org/u1f320/filer/db" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/rs/zerolog/log" +) + +type App struct { + chi.Router + + db *db.DB +} + +func New(db *db.DB) *App { + r := chi.NewMux() + r.Use(middleware.Recoverer) + r.Use(requestLogger) + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "OPTIONS"}, + MaxAge: 300, + })) + + app := &App{ + Router: r, + db: db, + } + + app.Get("/{hash}/{filename}", app.showFile) + app.Post("/upload", app.uploadFile) + + return app +} + +var portRegex = regexp.MustCompile(`^\d{2,}$`) + +func (app *App) Run(ctx context.Context) { + port := os.Getenv("PORT") + if !portRegex.MatchString(port) { + log.Error().Str("port", port).Msg("$PORT is not a valid integer") + return + } + + listenAddr := ":" + port + + ech := make(chan error) + go func() { + ech <- http.ListenAndServe(listenAddr, app) + }() + + log.Info().Str("port", port).Msg("Running server") + if err := <-ech; err != nil { + log.Err(err).Msg("Running server") + } +}