first commit
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
homework.db
|
||||
config.ini
|
||||
*.log
|
||||
tmp/
|
||||
.env
|
||||
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Binaries
|
||||
homework-manager
|
||||
homework-manager.exe
|
||||
server
|
||||
server.exe
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Vendor
|
||||
vendor/
|
||||
|
||||
# Local Config
|
||||
config.ini
|
||||
.env
|
||||
|
||||
# Editor/IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# OS
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# Builder stage
|
||||
FROM golang:1.24-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install git if needed for fetching dependencies (sometimes needed even with go modules)
|
||||
# RUN apk add --no-cache git
|
||||
|
||||
# Copy go mod and sum files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
# CGO_ENABLED=0 for static binary since we are using pure Go SQLite driver (glebarez/sqlite)
|
||||
RUN CGO_ENABLED=0 go build -o server ./cmd/server/main.go
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/server .
|
||||
|
||||
# Copy web assets (templates, static files)
|
||||
COPY --from=builder /app/web ./web
|
||||
|
||||
# Expose port (adjust if your app uses a different port)
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
ENTRYPOINT ["./server"]
|
||||
651
LICENSE.md
Normal file
651
LICENSE.md
Normal file
@@ -0,0 +1,651 @@
|
||||
GNU Affero General Public License
|
||||
=================================
|
||||
|
||||
_Version 3, 19 November 2007_
|
||||
_Copyright © 2007 Free Software Foundation, Inc. <<http://fsf.org/>>_
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
## Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: **(1)** assert copyright on the software, and **(2)** offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
## TERMS AND CONDITIONS
|
||||
|
||||
### 0. Definitions
|
||||
|
||||
“This License” refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
“Copyright” also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
“The Program” refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as “you”. “Licensees” and
|
||||
“recipients” may be individuals or organizations.
|
||||
|
||||
To “modify” a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a “modified version” of the
|
||||
earlier work or a work “based on” the earlier work.
|
||||
|
||||
A “covered work” means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To “propagate” a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To “convey” a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays “Appropriate Legal Notices”
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that **(1)** displays an appropriate copyright notice, and **(2)**
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
### 1. Source Code
|
||||
|
||||
The “source code” for a work means the preferred form of the work
|
||||
for making modifications to it. “Object code” means any non-source
|
||||
form of a work.
|
||||
|
||||
A “Standard Interface” means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The “System Libraries” of an executable work include anything, other
|
||||
than the work as a whole, that **(a)** is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and **(b)** serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
“Major Component”, in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The “Corresponding Source” for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
### 2. Basic Permissions
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
### 3. Protecting Users' Legal Rights From Anti-Circumvention Law
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
### 4. Conveying Verbatim Copies
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
### 5. Conveying Modified Source Versions
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
* **a)** The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
* **b)** The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section 7.
|
||||
This requirement modifies the requirement in section 4 to
|
||||
“keep intact all notices”.
|
||||
* **c)** You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
* **d)** If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
“aggregate” if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
### 6. Conveying Non-Source Forms
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
* **a)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
* **b)** Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either **(1)** a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or **(2)** access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
* **c)** Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
* **d)** Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
* **e)** Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A “User Product” is either **(1)** a “consumer product”, which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or **(2)** anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, “normally used” refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
“Installation Information” for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
### 7. Additional Terms
|
||||
|
||||
“Additional permissions” are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
* **a)** Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
* **b)** Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
* **c)** Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
* **d)** Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
* **e)** Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
* **f)** Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered “further
|
||||
restrictions” within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
### 8. Termination
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated **(a)**
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and **(b)** permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
### 9. Acceptance Not Required for Having Copies
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
### 10. Automatic Licensing of Downstream Recipients
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An “entity transaction” is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
### 11. Patents
|
||||
|
||||
A “contributor” is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's “contributor version”.
|
||||
|
||||
A contributor's “essential patent claims” are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, “control” includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a “patent license” is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To “grant” such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either **(1)** cause the Corresponding Source to be so
|
||||
available, or **(2)** arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or **(3)** arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. “Knowingly relying” means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is “discriminatory” if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license **(a)** in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or **(b)** primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
### 12. No Surrender of Others' Freedom
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
### 13. Remote Network Interaction; Use with the GNU General Public License
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
### 14. Revised Versions of this License
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License “or any later version” applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
### 15. Disclaimer of Warranty
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
### 16. Limitation of Liability
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
### 17. Interpretation of Sections 15 and 16
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
## How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the “copyright” line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a “Source” link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a “copyright disclaimer” for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<<http://www.gnu.org/licenses/>>.
|
||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Homework Manager
|
||||
|
||||
シンプルな課題管理アプリケーションです。学生の課題管理を効率化するために設計されています。
|
||||
|
||||
## 特徴
|
||||
|
||||
- **課題管理**: 課題の登録、編集、削除、完了状況の管理
|
||||
- **ダッシュボード**: 期限切れ、本日期限、今週期限の課題をひと目で確認
|
||||
- **API対応**: 外部連携用のRESTful API (APIキー認証)
|
||||
- **セキュリティ**:
|
||||
- CSRF対策
|
||||
- レート制限 (Rate Limiting)
|
||||
- セキュアなセッション管理
|
||||
- **ポータビリティ**: Pure Go SQLiteドライバー使用により、CGO不要でどこでも動作
|
||||
|
||||
## TODO
|
||||
|
||||
- 取り組み目安時間の登録
|
||||
- SNS連携(もしかしたらやるかも)
|
||||
|
||||
## ドキュメント
|
||||
|
||||
詳細な仕様やAPIドキュメントは `docs/` ディレクトリを参照してください。
|
||||
|
||||
- [仕様書](docs/SPECIFICATION.md): 機能詳細、データモデル、設定項目
|
||||
- [APIドキュメント](docs/API.md): APIのエンドポイント、リクエスト/レスポンス形式
|
||||
|
||||
## 前提条件
|
||||
|
||||
- Go 1.24 以上
|
||||
|
||||
## インストール方法
|
||||
|
||||
1. **リポジトリのクローン**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd Homework-Manager
|
||||
```
|
||||
|
||||
2. **依存関係のダウンロード**
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
3. **アプリケーションのビルド**
|
||||
```bash
|
||||
go build -o homework-manager cmd/server/main.go
|
||||
```
|
||||
|
||||
4. **設定ファイルの準備**
|
||||
サンプル設定ファイルをコピーして、`config.ini` を作成します。
|
||||
|
||||
```bash
|
||||
cp config.ini.example config.ini
|
||||
```
|
||||
※ Windows (PowerShell): `Copy-Item config.ini.example config.ini`
|
||||
|
||||
**重要**: 本番環境で使用する場合は、必ず `[session] secret` と `[security] csrf_secret` を変更してください。
|
||||
|
||||
5. **アプリケーションの実行**
|
||||
```bash
|
||||
./homework-manager
|
||||
```
|
||||
※ Windows (PowerShell): `.\homework-manager.exe`
|
||||
|
||||
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||
|
||||
## Dockerでの実行
|
||||
|
||||
DockerおよびDocker Composeがインストールされている環境では、以下の手順で簡単に起動できます。
|
||||
|
||||
1. **設定ファイルの準備**
|
||||
```bash
|
||||
cp config.ini.example config.ini
|
||||
```
|
||||
※ 必須です。これを行わないとDockerがディレクトリとして作成してしまい起動に失敗します。
|
||||
|
||||
2. **コンテナの起動**
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
3. **アクセスの確認**
|
||||
ブラウザで `http://localhost:8080` にアクセスしてください。
|
||||
|
||||
|
||||
## 更新方法
|
||||
|
||||
1. `git pull` で最新コードを取得
|
||||
2. `go build -o homework-manager cmd/server/main.go` で再ビルド
|
||||
3. アプリケーションを再起動
|
||||
|
||||
## ライセンス
|
||||
|
||||
本ソフトウェアのライセンスはAGPLv3 (GNU Affero General Public License v3)です。
|
||||
詳しくはLICENSEファイルをご覧ください。
|
||||
55
config.ini.example
Normal file
55
config.ini.example
Normal file
@@ -0,0 +1,55 @@
|
||||
; Homework Manager 設定ファイル
|
||||
; 環境変数が設定されている場合はそちらが優先されます
|
||||
|
||||
[server]
|
||||
; サーバーポート
|
||||
port = 8080
|
||||
|
||||
; デバッグモード (true/false)
|
||||
debug = true
|
||||
|
||||
[database]
|
||||
; データベースドライバー: sqlite, mysql, postgres
|
||||
driver = sqlite
|
||||
|
||||
; SQLite用設定
|
||||
path = homework.db
|
||||
|
||||
; MySQL/PostgreSQL用設定(driverをmysqlまたはpostgresに変更して使用)
|
||||
; host = localhost
|
||||
; port = 3306
|
||||
; user = root
|
||||
; password =
|
||||
; name = homework_manager
|
||||
|
||||
; PostgreSQL例:
|
||||
; driver = postgres
|
||||
; host = localhost
|
||||
; port = 5432
|
||||
; user = postgres
|
||||
; password = secret
|
||||
; name = homework_manager
|
||||
|
||||
[session]
|
||||
; セッション暗号化キー(本番環境では必ず変更してください)
|
||||
secret = homework-manager-secret-key-change-in-production
|
||||
|
||||
[auth]
|
||||
; 新規ユーザー登録を許可するか (true/false)
|
||||
; falseにすると登録ページが無効化されます
|
||||
allow_registration = true
|
||||
|
||||
[security]
|
||||
; HTTPS使用時はtrueに設定(Secure cookie属性が有効になります)
|
||||
https = false
|
||||
|
||||
; CSRFトークン秘密鍵(本番環境では必ず変更してください)
|
||||
csrf_secret = change-this-to-a-secure-random-string
|
||||
# Enable rate limiting
|
||||
rate_limit_enabled = true
|
||||
# Max requests per window
|
||||
rate_limit_requests = 100
|
||||
# Window size in seconds
|
||||
rate_limit_window = 60
|
||||
# Trusted proxies (comma separated IP addresses or CIDR)
|
||||
# trusted_proxies = 127.0.0.1, 10.0.0.0/8
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./homework.db:/app/homework.db
|
||||
- ./config.ini:/app/config.ini
|
||||
environment:
|
||||
- TZ=Asia/Tokyo
|
||||
restart: unless-stopped
|
||||
419
docs/API.md
Normal file
419
docs/API.md
Normal file
@@ -0,0 +1,419 @@
|
||||
# Super Homework Manager API ドキュメント
|
||||
|
||||
## 概要
|
||||
|
||||
Super Homework Manager REST APIは、課題管理機能をプログラムから利用するためのAPIです。
|
||||
|
||||
- **ベースURL**: `/api/v1`
|
||||
- **認証方式**: APIキー認証
|
||||
- **レスポンス形式**: JSON
|
||||
|
||||
---
|
||||
|
||||
## 認証
|
||||
|
||||
すべてのAPIエンドポイントはAPIキー認証が必要です。
|
||||
|
||||
### APIキーの取得
|
||||
|
||||
1. 管理者アカウントでログイン
|
||||
2. 管理画面 → APIキー管理へ移動
|
||||
3. 新規APIキーを発行
|
||||
|
||||
### 認証ヘッダー
|
||||
|
||||
```
|
||||
X-API-Key: hm_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
### 認証エラー
|
||||
|
||||
| ステータスコード | レスポンス |
|
||||
|------------------|------------|
|
||||
| 401 Unauthorized | `{"error": "API key required"}` |
|
||||
| 401 Unauthorized | `{"error": "Invalid API key"}` |
|
||||
|
||||
---
|
||||
|
||||
## エンドポイント一覧
|
||||
|
||||
| メソッド | パス | 説明 |
|
||||
|----------|------|------|
|
||||
| GET | `/api/v1/assignments` | 課題一覧取得 |
|
||||
| GET | `/api/v1/assignments/:id` | 課題詳細取得 |
|
||||
| POST | `/api/v1/assignments` | 課題作成 |
|
||||
| PUT | `/api/v1/assignments/:id` | 課題更新 |
|
||||
| DELETE | `/api/v1/assignments/:id` | 課題削除 |
|
||||
| PATCH | `/api/v1/assignments/:id/toggle` | 完了状態トグル |
|
||||
|
||||
---
|
||||
|
||||
## 課題一覧取得
|
||||
|
||||
```
|
||||
GET /api/v1/assignments
|
||||
```
|
||||
|
||||
### クエリパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `filter` | string | フィルタ: `pending`, `completed`, `overdue` (省略時: 全件) |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"assignments": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T10:00:00+09:00",
|
||||
"updated_at": "2025-01-10T10:00:00+09:00"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
# 全件取得
|
||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments
|
||||
|
||||
# 未完了のみ取得
|
||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=pending
|
||||
|
||||
# 期限切れのみ取得
|
||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments?filter=overdue
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 課題詳細取得
|
||||
|
||||
```
|
||||
GET /api/v1/assignments/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 課題ID |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T10:00:00+09:00",
|
||||
"updated_at": "2025-01-10T10:00:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -H "X-API-Key: hm_xxx" http://localhost:8080/api/v1/assignments/1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 課題作成
|
||||
|
||||
```
|
||||
POST /api/v1/assignments
|
||||
```
|
||||
|
||||
### リクエストボディ
|
||||
|
||||
| フィールド | 型 | 必須 | 説明 |
|
||||
|------------|------|------|------|
|
||||
| `title` | string | ✅ | 課題タイトル |
|
||||
| `description` | string | | 説明 |
|
||||
| `subject` | string | | 教科・科目 |
|
||||
| `due_date` | string | ✅ | 提出期限(RFC3339 または `YYYY-MM-DDTHH:MM` または `YYYY-MM-DD`) |
|
||||
|
||||
### リクエスト例
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "英語エッセイ",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"due_date": "2025-01-20T17:00"
|
||||
}
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**201 Created**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"user_id": 1,
|
||||
"title": "英語エッセイ",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"due_date": "2025-01-20T17:00:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T11:00:00+09:00",
|
||||
"updated_at": "2025-01-10T11:00:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
**400 Bad Request**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Invalid input: title and due_date are required"
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "X-API-Key: hm_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"英語エッセイ","due_date":"2025-01-20"}' \
|
||||
http://localhost:8080/api/v1/assignments
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 課題更新
|
||||
|
||||
```
|
||||
PUT /api/v1/assignments/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 課題ID |
|
||||
|
||||
### リクエストボディ
|
||||
|
||||
すべてのフィールドはオプションです。省略されたフィールドは既存の値を維持します。
|
||||
|
||||
| フィールド | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `title` | string | 課題タイトル |
|
||||
| `description` | string | 説明 |
|
||||
| `subject` | string | 教科・科目 |
|
||||
| `due_date` | string | 提出期限 |
|
||||
|
||||
### リクエスト例
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "英語エッセイ(修正版)",
|
||||
"due_date": "2025-01-25T17:00"
|
||||
}
|
||||
```
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 2,
|
||||
"user_id": 1,
|
||||
"title": "英語エッセイ(修正版)",
|
||||
"description": "テーマ自由、1000語以上",
|
||||
"subject": "英語",
|
||||
"due_date": "2025-01-25T17:00:00+09:00",
|
||||
"is_completed": false,
|
||||
"created_at": "2025-01-10T11:00:00+09:00",
|
||||
"updated_at": "2025-01-10T12:00:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -X PUT \
|
||||
-H "X-API-Key: hm_xxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"更新されたタイトル"}' \
|
||||
http://localhost:8080/api/v1/assignments/2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 課題削除
|
||||
|
||||
```
|
||||
DELETE /api/v1/assignments/:id
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 課題ID |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Assignment deleted"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -X DELETE \
|
||||
-H "X-API-Key: hm_xxx" \
|
||||
http://localhost:8080/api/v1/assignments/2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完了状態トグル
|
||||
|
||||
課題の完了状態を切り替えます(未完了 ↔ 完了)。
|
||||
|
||||
```
|
||||
PATCH /api/v1/assignments/:id/toggle
|
||||
```
|
||||
|
||||
### パスパラメータ
|
||||
|
||||
| パラメータ | 型 | 説明 |
|
||||
|------------|------|------|
|
||||
| `id` | integer | 課題ID |
|
||||
|
||||
### レスポンス
|
||||
|
||||
**200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"title": "数学レポート",
|
||||
"description": "第5章の練習問題",
|
||||
"subject": "数学",
|
||||
"due_date": "2025-01-15T23:59:00+09:00",
|
||||
"is_completed": true,
|
||||
"completed_at": "2025-01-12T14:30:00+09:00",
|
||||
"created_at": "2025-01-10T10:00:00+09:00",
|
||||
"updated_at": "2025-01-12T14:30:00+09:00"
|
||||
}
|
||||
```
|
||||
|
||||
**404 Not Found**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Assignment not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 例
|
||||
|
||||
```bash
|
||||
curl -X PATCH \
|
||||
-H "X-API-Key: hm_xxx" \
|
||||
http://localhost:8080/api/v1/assignments/1/toggle
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## エラーレスポンス
|
||||
|
||||
すべてのエラーレスポンスは以下の形式で返されます:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "エラーメッセージ"
|
||||
}
|
||||
```
|
||||
|
||||
### 共通エラーコード
|
||||
|
||||
| ステータスコード | 説明 |
|
||||
|------------------|------|
|
||||
| 400 Bad Request | リクエストの形式が不正 |
|
||||
| 401 Unauthorized | 認証エラー |
|
||||
| 404 Not Found | リソースが見つからない |
|
||||
| 500 Internal Server Error | サーバー内部エラー |
|
||||
|
||||
---
|
||||
|
||||
## 日付形式
|
||||
|
||||
APIは以下の日付形式を受け付けます(優先度順):
|
||||
|
||||
1. **RFC3339**: `2025-01-15T23:59:00+09:00`
|
||||
2. **日時形式**: `2025-01-15T23:59`
|
||||
3. **日付のみ**: `2025-01-15`(時刻は23:59に設定)
|
||||
|
||||
レスポンスの日付はすべてRFC3339形式で返されます。
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
アプリケーションレベルでのRate Limitingが実装されています。
|
||||
|
||||
- **制限単位**: IPアドレスごと
|
||||
- **デフォルト制限**: 100リクエスト / 60秒
|
||||
- **超過時レスポンス**: `429 Too Many Requests`
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "リクエスト数が制限を超えました。しばらくしてからお試しください。"
|
||||
}
|
||||
```
|
||||
|
||||
設定ファイル (`config.ini`) または環境変数で制限値を変更可能です。
|
||||
252
docs/SPECIFICATION.md
Normal file
252
docs/SPECIFICATION.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Super Homework Manager 仕様書
|
||||
|
||||
## 1. 概要
|
||||
|
||||
Super Homework Managerは、学生の課題管理を支援するWebアプリケーションです。Go言語とGinフレームワークを使用して構築されており、課題の登録・管理、期限追跡、完了状況の管理機能を提供します。
|
||||
|
||||
### 1.1 技術スタック
|
||||
|
||||
| 項目 | 技術 |
|
||||
|------|------|
|
||||
| 言語 | Go |
|
||||
| Webフレームワーク | Gin |
|
||||
| データベース | SQLite (GORM with Pure Go driver - glebarez/sqlite) |
|
||||
| セッション管理 | gin-contrib/sessions (Cookie store) |
|
||||
| テンプレートエンジン | Go html/template |
|
||||
| コンテナ | Docker対応 |
|
||||
|
||||
### 1.2 ディレクトリ構成
|
||||
|
||||
```
|
||||
homework-manager/
|
||||
├── cmd/server/ # アプリケーションエントリポイント
|
||||
├── internal/
|
||||
│ ├── config/ # 設定読み込み
|
||||
│ ├── database/ # データベース接続・マイグレーション
|
||||
│ ├── handler/ # HTTPハンドラ
|
||||
│ ├── middleware/ # ミドルウェア
|
||||
│ ├── models/ # データモデル
|
||||
│ ├── repository/ # データアクセス層
|
||||
│ └── service/ # ビジネスロジック
|
||||
├── web/
|
||||
│ ├── static/ # 静的ファイル (CSS, JS)
|
||||
│ └── templates/ # HTMLテンプレート
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. データモデル
|
||||
|
||||
### 2.1 User(ユーザー)
|
||||
|
||||
ユーザー情報を管理するモデル。
|
||||
|
||||
| フィールド | 型 | 説明 | 制約 |
|
||||
|------------|------|------|------|
|
||||
| ID | uint | ユーザーID | Primary Key |
|
||||
| Email | string | メールアドレス | Unique, Not Null |
|
||||
| PasswordHash | string | パスワードハッシュ | Not Null |
|
||||
| Name | string | 表示名 | Not Null |
|
||||
| Role | string | 権限 (`user` or `admin`) | Default: `user` |
|
||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||
|
||||
### 2.2 Assignment(課題)
|
||||
|
||||
課題情報を管理するモデル。
|
||||
|
||||
| フィールド | 型 | 説明 | 制約 |
|
||||
|------------|------|------|------|
|
||||
| ID | uint | 課題ID | Primary Key |
|
||||
| UserID | uint | 所有ユーザーID | Not Null, Index |
|
||||
| Title | string | 課題タイトル | Not Null |
|
||||
| Description | string | 説明 | - |
|
||||
| Subject | string | 教科・科目 | - |
|
||||
| DueDate | time.Time | 提出期限 | Not Null |
|
||||
| IsCompleted | bool | 完了フラグ | Default: false |
|
||||
| CompletedAt | *time.Time | 完了日時 | Nullable |
|
||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||
| UpdatedAt | time.Time | 更新日時 | 自動更新 |
|
||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||
|
||||
### 2.3 APIKey(APIキー)
|
||||
|
||||
REST API認証用のAPIキーを管理するモデル。
|
||||
|
||||
| フィールド | 型 | 説明 | 制約 |
|
||||
|------------|------|------|------|
|
||||
| ID | uint | APIキーID | Primary Key |
|
||||
| UserID | uint | 所有ユーザーID | Not Null, Index |
|
||||
| Name | string | キー名 | Not Null |
|
||||
| KeyHash | string | キーハッシュ | Unique, Not Null |
|
||||
| LastUsed | *time.Time | 最終使用日時 | Nullable |
|
||||
| CreatedAt | time.Time | 作成日時 | 自動設定 |
|
||||
| DeletedAt | gorm.DeletedAt | 論理削除日時 | ソフトデリート |
|
||||
|
||||
---
|
||||
|
||||
## 3. 認証・認可
|
||||
|
||||
### 3.1 Web認証
|
||||
|
||||
- **セッションベース認証**: Cookie storeを使用
|
||||
- **セッション有効期限**: 7日間
|
||||
- **パスワード要件**: 8文字以上
|
||||
- **パスワードハッシュ**: bcryptを使用
|
||||
- **CSRF対策**: 全フォームでのトークン検証
|
||||
|
||||
### 3.2 API認証
|
||||
|
||||
- **APIキー認証**: `X-API-Key` ヘッダーで認証
|
||||
- **キー形式**: `hm_` プレフィックス + 32文字のランダム文字列
|
||||
- **ハッシュ保存**: SHA-256でハッシュ化して保存
|
||||
|
||||
### 3.3 ユーザーロール
|
||||
|
||||
| ロール | 権限 |
|
||||
|--------|------|
|
||||
| `user` | 自分の課題のCRUD操作、プロフィール管理 |
|
||||
| `admin` | 全ユーザー管理、APIキー管理、ユーザー権限の変更 |
|
||||
※ 最初に登録されたユーザーには自動的に `admin` 権限が付与されます。2人目以降は `user` として登録されます。
|
||||
|
||||
---
|
||||
|
||||
## 4. 機能一覧
|
||||
|
||||
### 4.1 認証機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| 新規登録 | メールアドレス、パスワード、名前で登録 |
|
||||
| ログイン | メールアドレスとパスワードでログイン |
|
||||
| ログアウト | セッションをクリアしてログアウト |
|
||||
|
||||
### 4.2 課題管理機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| ダッシュボード | 課題の統計情報、本日期限の課題、期限切れ課題、今週期限の課題を表示 |
|
||||
| 課題一覧 | フィルタ付き(未完了/完了済み/期限切れ)で課題を一覧表示 |
|
||||
| 課題登録 | タイトル、説明、教科、提出期限を入力して新規登録 |
|
||||
| 課題編集 | 既存の課題情報を編集 |
|
||||
| 課題削除 | 課題を論理削除 |
|
||||
| 完了トグル | 課題の完了/未完了状態を切り替え |
|
||||
|
||||
### 4.3 プロフィール機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| プロフィール表示 | ユーザー情報を表示 |
|
||||
| プロフィール更新 | 表示名を変更 |
|
||||
| パスワード変更 | 現在のパスワードを確認後、新しいパスワードに変更 |
|
||||
|
||||
### 4.4 管理者機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| ユーザー一覧 | 全ユーザーを一覧表示 |
|
||||
| ユーザー削除 | ユーザーを論理削除(自分自身は削除不可) |
|
||||
| 権限変更 | ユーザーのロールを変更(自分自身は変更不可) |
|
||||
| APIキー一覧 | 全APIキーを一覧表示 |
|
||||
| APIキー発行 | 新規APIキーを発行(発行時のみ平文表示) |
|
||||
| APIキー削除 | APIキーを削除 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 設定
|
||||
|
||||
### 5.1 設定ファイル (config.ini)
|
||||
|
||||
アプリケーションは `config.ini` ファイルから設定を読み込みます。`config.ini.example` をコピーして使用してください。
|
||||
|
||||
```ini
|
||||
[server]
|
||||
port = 8080
|
||||
debug = true
|
||||
|
||||
[database]
|
||||
driver = sqlite
|
||||
path = homework.db
|
||||
|
||||
[session]
|
||||
secret = your-secure-secret-key
|
||||
|
||||
[auth]
|
||||
allow_registration = true
|
||||
|
||||
[security]
|
||||
https = false
|
||||
csrf_secret = your-secure-csrf-secret
|
||||
rate_limit_enabled = true
|
||||
rate_limit_requests = 100
|
||||
rate_limit_window = 60
|
||||
```
|
||||
|
||||
### 5.2 設定項目
|
||||
|
||||
| セクション | キー | 説明 | デフォルト値 |
|
||||
|------------|------|------|--------------|
|
||||
| `server` | `port` | サーバーポート | `8080` |
|
||||
| `server` | `debug` | デバッグモード | `true` |
|
||||
| `database` | `driver` | DBドライバー (sqlite, mysql, postgres) | `sqlite` |
|
||||
| `database` | `path` | SQLiteファイルパス | `homework.db` |
|
||||
| `session` | `secret` | セッション暗号化キー | (必須) |
|
||||
| `auth` | `allow_registration` | 新規登録許可 | `true` |
|
||||
| `security` | `https` | HTTPS設定(Secure Cookie) | `false` |
|
||||
| `security` | `csrf_secret` | CSRFトークン秘密鍵 | (必須) |
|
||||
| `security` | `rate_limit_enabled` | レート制限有効化 | `true` |
|
||||
| `security` | `rate_limit_requests` | 期間あたりの最大リクエスト数 | `100` |
|
||||
| `security` | `rate_limit_window` | 期間(秒) | `60` |
|
||||
|
||||
### 5.3 環境変数
|
||||
|
||||
環境変数が設定されている場合、config.iniの設定より優先されます。
|
||||
|
||||
| 変数名 | 説明 |
|
||||
|--------|------|
|
||||
| `PORT` | サーバーポート |
|
||||
| `DATABASE_DRIVER` | データベースドライバー |
|
||||
| `DATABASE_PATH` | SQLiteデータベースファイルパス |
|
||||
| `SESSION_SECRET` | セッション暗号化キー |
|
||||
| `CSRF_SECRET` | CSRFトークン秘密鍵 |
|
||||
| `GIN_MODE` | `release` でリリースモード(debug=false) |
|
||||
| `ALLOW_REGISTRATION` | 新規登録許可 (true/false) |
|
||||
| `HTTPS` | HTTPSモード (true/false) |
|
||||
| `TRUSTED_PROXIES` | 信頼するプロキシのリスト |
|
||||
|
||||
### 5.4 設定の優先順位
|
||||
|
||||
1. 環境変数(最優先)
|
||||
2. config.ini
|
||||
3. デフォルト値
|
||||
|
||||
---
|
||||
|
||||
## 6. セキュリティ
|
||||
|
||||
### 6.1 実装済みセキュリティ機能
|
||||
|
||||
- **パスワードハッシュ化**: bcryptによるソルト付きハッシュ
|
||||
- **セッションセキュリティ**: HttpOnly Cookie
|
||||
- **入力バリデーション**: 各ハンドラで基本的な入力検証
|
||||
- **CSFR対策**: Double Submit Cookieパターンまたは同期トークンによるCSRF保護
|
||||
- **レート制限**: IPベースのリクエスト制限によるDoS対策
|
||||
- **論理削除**: データの完全削除を防ぐソフトデリート
|
||||
- **権限チェック**: ミドルウェアによるロールベースアクセス制御
|
||||
- **Secure Cookie**: HTTPS設定時のSecure属性付与
|
||||
|
||||
### 6.2 推奨される本番環境設定
|
||||
|
||||
- `SESSION_SECRET` と `CSRF_SECRET` を強力なランダム文字列に変更
|
||||
- HTTPSを有効化し、`HTTPS=true` を設定
|
||||
- `GIN_MODE=release` を設定
|
||||
- 必要に応じて `TRUSTED_PROXIES` を設定
|
||||
|
||||
---
|
||||
|
||||
## 7. ライセンス
|
||||
|
||||
AGPLv3 (GNU Affero General Public License v3)
|
||||
69
go.mod
Normal file
69
go.mod
Normal file
@@ -0,0 +1,69 @@
|
||||
module homework-manager
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.11
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/sessions v1.0.4
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
modernc.org/libc v1.67.2 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.42.2 // indirect
|
||||
)
|
||||
174
go.sum
Normal file
174
go.sum
Normal file
@@ -0,0 +1,174 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U=
|
||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
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=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.2 h1:ZbNmly1rcbjhot5jlOZG0q4p5VwFfjwWqZ5rY2xxOXo=
|
||||
modernc.org/libc v1.67.2/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
182
internal/config/config.go
Normal file
182
internal/config/config.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string // sqlite, mysql, postgres
|
||||
Path string // SQLiteの場合パス
|
||||
Host string
|
||||
Port string
|
||||
User string
|
||||
Password string
|
||||
Name string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
SessionSecret string
|
||||
Debug bool
|
||||
AllowRegistration bool
|
||||
HTTPS bool
|
||||
CSRFSecret string
|
||||
RateLimitEnabled bool
|
||||
RateLimitRequests int
|
||||
RateLimitWindow int
|
||||
TrustedProxies []string
|
||||
Database DatabaseConfig
|
||||
}
|
||||
|
||||
func Load(configPath string) *Config {
|
||||
cfg := &Config{
|
||||
Port: "8080",
|
||||
SessionSecret: "",
|
||||
Debug: true,
|
||||
AllowRegistration: true,
|
||||
HTTPS: false,
|
||||
CSRFSecret: "",
|
||||
RateLimitEnabled: true,
|
||||
RateLimitRequests: 100,
|
||||
RateLimitWindow: 60,
|
||||
Database: DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: "homework.db",
|
||||
Host: "localhost",
|
||||
Port: "3306",
|
||||
User: "root",
|
||||
Password: "",
|
||||
Name: "homework_manager",
|
||||
},
|
||||
}
|
||||
|
||||
if configPath == "" {
|
||||
configPath = "config.ini"
|
||||
}
|
||||
|
||||
if iniFile, err := ini.Load(configPath); err == nil {
|
||||
log.Printf("Loading configuration from %s", configPath)
|
||||
|
||||
section := iniFile.Section("server")
|
||||
if section.HasKey("port") {
|
||||
cfg.Port = section.Key("port").String()
|
||||
}
|
||||
if section.HasKey("debug") {
|
||||
cfg.Debug = section.Key("debug").MustBool(true)
|
||||
}
|
||||
|
||||
section = iniFile.Section("database")
|
||||
if section.HasKey("driver") {
|
||||
cfg.Database.Driver = section.Key("driver").String()
|
||||
}
|
||||
if section.HasKey("path") {
|
||||
cfg.Database.Path = section.Key("path").String()
|
||||
}
|
||||
if section.HasKey("host") {
|
||||
cfg.Database.Host = section.Key("host").String()
|
||||
}
|
||||
if section.HasKey("port") {
|
||||
cfg.Database.Port = section.Key("port").String()
|
||||
}
|
||||
if section.HasKey("user") {
|
||||
cfg.Database.User = section.Key("user").String()
|
||||
}
|
||||
if section.HasKey("password") {
|
||||
cfg.Database.Password = section.Key("password").String()
|
||||
}
|
||||
if section.HasKey("name") {
|
||||
cfg.Database.Name = section.Key("name").String()
|
||||
}
|
||||
|
||||
section = iniFile.Section("session")
|
||||
if section.HasKey("secret") {
|
||||
cfg.SessionSecret = section.Key("secret").String()
|
||||
}
|
||||
|
||||
section = iniFile.Section("auth")
|
||||
if section.HasKey("allow_registration") {
|
||||
cfg.AllowRegistration = section.Key("allow_registration").MustBool(true)
|
||||
}
|
||||
|
||||
section = iniFile.Section("security")
|
||||
if section.HasKey("https") {
|
||||
cfg.HTTPS = section.Key("https").MustBool(false)
|
||||
}
|
||||
if section.HasKey("csrf_secret") {
|
||||
cfg.CSRFSecret = section.Key("csrf_secret").String()
|
||||
}
|
||||
if section.HasKey("rate_limit_enabled") {
|
||||
cfg.RateLimitEnabled = section.Key("rate_limit_enabled").MustBool(true)
|
||||
}
|
||||
if section.HasKey("rate_limit_requests") {
|
||||
cfg.RateLimitRequests = section.Key("rate_limit_requests").MustInt(100)
|
||||
}
|
||||
if section.HasKey("rate_limit_window") {
|
||||
cfg.RateLimitWindow = section.Key("rate_limit_window").MustInt(60)
|
||||
}
|
||||
if section.HasKey("trusted_proxies") {
|
||||
proxies := section.Key("trusted_proxies").String()
|
||||
if proxies != "" {
|
||||
cfg.TrustedProxies = []string{proxies}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Println("config.ini not found, using environment variables or defaults")
|
||||
}
|
||||
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
cfg.Port = port
|
||||
}
|
||||
if dbDriver := os.Getenv("DATABASE_DRIVER"); dbDriver != "" {
|
||||
cfg.Database.Driver = dbDriver
|
||||
}
|
||||
if dbPath := os.Getenv("DATABASE_PATH"); dbPath != "" {
|
||||
cfg.Database.Path = dbPath
|
||||
}
|
||||
if dbHost := os.Getenv("DATABASE_HOST"); dbHost != "" {
|
||||
cfg.Database.Host = dbHost
|
||||
}
|
||||
if dbPort := os.Getenv("DATABASE_PORT"); dbPort != "" {
|
||||
cfg.Database.Port = dbPort
|
||||
}
|
||||
if dbUser := os.Getenv("DATABASE_USER"); dbUser != "" {
|
||||
cfg.Database.User = dbUser
|
||||
}
|
||||
if dbPassword := os.Getenv("DATABASE_PASSWORD"); dbPassword != "" {
|
||||
cfg.Database.Password = dbPassword
|
||||
}
|
||||
if dbName := os.Getenv("DATABASE_NAME"); dbName != "" {
|
||||
cfg.Database.Name = dbName
|
||||
}
|
||||
if sessionSecret := os.Getenv("SESSION_SECRET"); sessionSecret != "" {
|
||||
cfg.SessionSecret = sessionSecret
|
||||
}
|
||||
if os.Getenv("GIN_MODE") == "release" {
|
||||
cfg.Debug = false
|
||||
}
|
||||
if allowReg := os.Getenv("ALLOW_REGISTRATION"); allowReg != "" {
|
||||
cfg.AllowRegistration = allowReg == "true" || allowReg == "1"
|
||||
}
|
||||
if https := os.Getenv("HTTPS"); https != "" {
|
||||
cfg.HTTPS = https == "true" || https == "1"
|
||||
}
|
||||
if csrfSecret := os.Getenv("CSRF_SECRET"); csrfSecret != "" {
|
||||
cfg.CSRFSecret = csrfSecret
|
||||
}
|
||||
if trustedProxies := os.Getenv("TRUSTED_PROXIES"); trustedProxies != "" {
|
||||
cfg.TrustedProxies = []string{trustedProxies}
|
||||
}
|
||||
|
||||
if cfg.SessionSecret == "" {
|
||||
log.Fatal("FATAL: Session secret is not set. Please set it in config.ini ([session] secret) or via SESSION_SECRET environment variable.")
|
||||
}
|
||||
if cfg.CSRFSecret == "" {
|
||||
log.Fatal("FATAL: CSRF secret is not set. Please set it in config.ini ([security] csrf_secret) or via CSRF_SECRET environment variable.")
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
79
internal/database/database.go
Normal file
79
internal/database/database.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/models"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func Connect(dbConfig config.DatabaseConfig, debug bool) error {
|
||||
var logMode logger.LogLevel
|
||||
if debug {
|
||||
logMode = logger.Info
|
||||
} else {
|
||||
logMode = logger.Silent
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logMode),
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
|
||||
switch dbConfig.Driver {
|
||||
case "mysql":
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
dbConfig.User,
|
||||
dbConfig.Password,
|
||||
dbConfig.Host,
|
||||
dbConfig.Port,
|
||||
dbConfig.Name,
|
||||
)
|
||||
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
|
||||
|
||||
case "postgres":
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
dbConfig.Host,
|
||||
dbConfig.Port,
|
||||
dbConfig.User,
|
||||
dbConfig.Password,
|
||||
dbConfig.Name,
|
||||
)
|
||||
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
|
||||
|
||||
case "sqlite":
|
||||
fallthrough
|
||||
default:
|
||||
db, err = gorm.Open(sqlite.Open(dbConfig.Path), gormConfig)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DB = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func Migrate() error {
|
||||
return DB.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Assignment{},
|
||||
&models.APIKey{},
|
||||
)
|
||||
}
|
||||
|
||||
func GetDB() *gorm.DB {
|
||||
return DB
|
||||
}
|
||||
|
||||
165
internal/handler/admin_handler.go
Normal file
165
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
adminService *service.AdminService
|
||||
apiKeyService *service.APIKeyService
|
||||
}
|
||||
|
||||
func NewAdminHandler() *AdminHandler {
|
||||
return &AdminHandler{
|
||||
adminService: service.NewAdminService(),
|
||||
apiKeyService: service.NewAPIKeyService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) getUserID(c *gin.Context) uint {
|
||||
userID, _ := c.Get(middleware.UserIDKey)
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Index(c *gin.Context) {
|
||||
users, _ := h.adminService.GetAllUsers()
|
||||
currentUserID := h.getUserID(c)
|
||||
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||
"title": "ユーザー管理",
|
||||
"users": users,
|
||||
"currentUserID": currentUserID,
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
adminID := h.getUserID(c)
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なユーザーID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteUser(adminID, uint(targetID))
|
||||
if err != nil {
|
||||
users, _ := h.adminService.GetAllUsers()
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||
"title": "ユーザー管理",
|
||||
"users": users,
|
||||
"currentUserID": adminID,
|
||||
"error": err.Error(),
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ChangeRole(c *gin.Context) {
|
||||
adminID := h.getUserID(c)
|
||||
targetID, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なユーザーID"})
|
||||
return
|
||||
}
|
||||
newRole := c.PostForm("role")
|
||||
|
||||
err = h.adminService.ChangeRole(adminID, uint(targetID), newRole)
|
||||
if err != nil {
|
||||
users, _ := h.adminService.GetAllUsers()
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/users.html", gin.H{
|
||||
"title": "ユーザー管理",
|
||||
"users": users,
|
||||
"currentUserID": adminID,
|
||||
"error": err.Error(),
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/users")
|
||||
}
|
||||
|
||||
func (h *AdminHandler) APIKeys(c *gin.Context) {
|
||||
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||
"title": "APIキー管理",
|
||||
"apiKeys": keys,
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) CreateAPIKey(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
keyName := c.PostForm("name")
|
||||
|
||||
plainKey, _, err := h.apiKeyService.CreateAPIKey(userID, keyName)
|
||||
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||
"title": "APIキー管理",
|
||||
"apiKeys": keys,
|
||||
"error": err.Error(),
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||
"title": "APIキー管理",
|
||||
"apiKeys": keys,
|
||||
"newKey": plainKey,
|
||||
"newKeyName": keyName,
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) DeleteAPIKey(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "無効なAPIキーID"})
|
||||
return
|
||||
}
|
||||
|
||||
err = h.apiKeyService.DeleteAPIKey(uint(id))
|
||||
if err != nil {
|
||||
keys, _ := h.apiKeyService.GetAllAPIKeys()
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "admin/api_keys.html", gin.H{
|
||||
"title": "APIキー管理",
|
||||
"apiKeys": keys,
|
||||
"error": err.Error(),
|
||||
"isAdmin": true,
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/admin/api-keys")
|
||||
}
|
||||
|
||||
398
internal/handler/api_handler.go
Normal file
398
internal/handler/api_handler.go
Normal file
@@ -0,0 +1,398 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type APIHandler struct {
|
||||
assignmentService *service.AssignmentService
|
||||
}
|
||||
|
||||
func NewAPIHandler() *APIHandler {
|
||||
return &APIHandler{
|
||||
assignmentService: service.NewAssignmentService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *APIHandler) getUserID(c *gin.Context) uint {
|
||||
userID, _ := c.Get(middleware.UserIDKey)
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
// ListAssignments returns all assignments for the authenticated user with pagination
|
||||
// GET /api/v1/assignments?filter=pending&page=1&page_size=20
|
||||
func (h *APIHandler) ListAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
filter := c.Query("filter") // pending, completed, overdue
|
||||
|
||||
// Parse pagination parameters
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
// Validate pagination parameters
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100 // Maximum page size to prevent abuse
|
||||
}
|
||||
|
||||
// Use paginated methods for filtered queries
|
||||
switch filter {
|
||||
case "completed":
|
||||
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": result.Assignments,
|
||||
"count": len(result.Assignments),
|
||||
"total_count": result.TotalCount,
|
||||
"total_pages": result.TotalPages,
|
||||
"current_page": result.CurrentPage,
|
||||
"page_size": result.PageSize,
|
||||
})
|
||||
return
|
||||
case "overdue":
|
||||
result, err := h.assignmentService.GetOverdueByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": result.Assignments,
|
||||
"count": len(result.Assignments),
|
||||
"total_count": result.TotalCount,
|
||||
"total_pages": result.TotalPages,
|
||||
"current_page": result.CurrentPage,
|
||||
"page_size": result.PageSize,
|
||||
})
|
||||
return
|
||||
case "pending":
|
||||
result, err := h.assignmentService.GetPendingByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": result.Assignments,
|
||||
"count": len(result.Assignments),
|
||||
"total_count": result.TotalCount,
|
||||
"total_pages": result.TotalPages,
|
||||
"current_page": result.CurrentPage,
|
||||
"page_size": result.PageSize,
|
||||
})
|
||||
return
|
||||
default:
|
||||
// For "all" filter, use simple pagination without a dedicated method
|
||||
assignments, err := h.assignmentService.GetAllByUser(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
// Manual pagination for all assignments
|
||||
totalCount := len(assignments)
|
||||
totalPages := (totalCount + pageSize - 1) / pageSize
|
||||
start := (page - 1) * pageSize
|
||||
end := start + pageSize
|
||||
if start > totalCount {
|
||||
start = totalCount
|
||||
}
|
||||
if end > totalCount {
|
||||
end = totalCount
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": assignments[start:end],
|
||||
"count": end - start,
|
||||
"total_count": totalCount,
|
||||
"total_pages": totalPages,
|
||||
"current_page": page,
|
||||
"page_size": pageSize,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ListPendingAssignments returns pending assignments with pagination
|
||||
// GET /api/v1/assignments/pending?page=1&page_size=20
|
||||
func (h *APIHandler) ListPendingAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
page, pageSize := h.parsePagination(c)
|
||||
|
||||
result, err := h.assignmentService.GetPendingByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
h.sendPaginatedResponse(c, result)
|
||||
}
|
||||
|
||||
// ListCompletedAssignments returns completed assignments with pagination
|
||||
// GET /api/v1/assignments/completed?page=1&page_size=20
|
||||
func (h *APIHandler) ListCompletedAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
page, pageSize := h.parsePagination(c)
|
||||
|
||||
result, err := h.assignmentService.GetCompletedByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
h.sendPaginatedResponse(c, result)
|
||||
}
|
||||
|
||||
// ListOverdueAssignments returns overdue assignments with pagination
|
||||
// GET /api/v1/assignments/overdue?page=1&page_size=20
|
||||
func (h *APIHandler) ListOverdueAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
page, pageSize := h.parsePagination(c)
|
||||
|
||||
result, err := h.assignmentService.GetOverdueByUserPaginated(userID, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
h.sendPaginatedResponse(c, result)
|
||||
}
|
||||
|
||||
// ListDueTodayAssignments returns assignments due today
|
||||
// GET /api/v1/assignments/due-today
|
||||
func (h *APIHandler) ListDueTodayAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
assignments, err := h.assignmentService.GetDueTodayByUser(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": assignments,
|
||||
"count": len(assignments),
|
||||
})
|
||||
}
|
||||
|
||||
// ListDueThisWeekAssignments returns assignments due within this week
|
||||
// GET /api/v1/assignments/due-this-week
|
||||
func (h *APIHandler) ListDueThisWeekAssignments(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
assignments, err := h.assignmentService.GetDueThisWeekByUser(userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch assignments"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": assignments,
|
||||
"count": len(assignments),
|
||||
})
|
||||
}
|
||||
|
||||
// parsePagination extracts and validates pagination parameters
|
||||
func (h *APIHandler) parsePagination(c *gin.Context) (page int, pageSize int) {
|
||||
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ = strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return page, pageSize
|
||||
}
|
||||
|
||||
// sendPaginatedResponse sends a standard paginated JSON response
|
||||
func (h *APIHandler) sendPaginatedResponse(c *gin.Context, result *service.PaginatedResult) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"assignments": result.Assignments,
|
||||
"count": len(result.Assignments),
|
||||
"total_count": result.TotalCount,
|
||||
"total_pages": result.TotalPages,
|
||||
"current_page": result.CurrentPage,
|
||||
"page_size": result.PageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAssignment returns a single assignment by ID
|
||||
// GET /api/v1/assignments/:id
|
||||
func (h *APIHandler) GetAssignment(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.GetByID(userID, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
// CreateAssignmentInput represents the JSON input for creating an assignment
|
||||
type CreateAssignmentInput struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `json:"priority"` // low, medium, high (default: medium)
|
||||
DueDate string `json:"due_date" binding:"required"` // RFC3339 or 2006-01-02T15:04
|
||||
}
|
||||
|
||||
// CreateAssignment creates a new assignment
|
||||
// POST /api/v1/assignments
|
||||
func (h *APIHandler) CreateAssignment(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
var input CreateAssignmentInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: title and due_date are required"})
|
||||
return
|
||||
}
|
||||
|
||||
dueDate, err := time.Parse(time.RFC3339, input.DueDate)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02", input.DueDate, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format. Use RFC3339 or 2006-01-02T15:04"})
|
||||
return
|
||||
}
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Create(userID, input.Title, input.Description, input.Subject, input.Priority, dueDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create assignment"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, assignment)
|
||||
}
|
||||
|
||||
// UpdateAssignmentInput represents the JSON input for updating an assignment
|
||||
type UpdateAssignmentInput struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `json:"priority"`
|
||||
DueDate string `json:"due_date"`
|
||||
}
|
||||
|
||||
// UpdateAssignment updates an existing assignment
|
||||
// PUT /api/v1/assignments/:id
|
||||
func (h *APIHandler) UpdateAssignment(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing assignment
|
||||
existing, err := h.assignmentService.GetByID(userID, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var input UpdateAssignmentInput
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
|
||||
return
|
||||
}
|
||||
|
||||
// Use existing values if not provided
|
||||
title := input.Title
|
||||
if title == "" {
|
||||
title = existing.Title
|
||||
}
|
||||
|
||||
description := input.Description
|
||||
subject := input.Subject
|
||||
priority := input.Priority
|
||||
if priority == "" {
|
||||
priority = existing.Priority
|
||||
}
|
||||
|
||||
dueDate := existing.DueDate
|
||||
if input.DueDate != "" {
|
||||
dueDate, err = time.Parse(time.RFC3339, input.DueDate)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02T15:04", input.DueDate, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid due_date format"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update assignment"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
|
||||
// DeleteAssignment deletes an assignment
|
||||
// DELETE /api/v1/assignments/:id
|
||||
func (h *APIHandler) DeleteAssignment(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.assignmentService.Delete(userID, uint(id)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Assignment deleted"})
|
||||
}
|
||||
|
||||
// ToggleAssignment toggles the completion status of an assignment
|
||||
// PATCH /api/v1/assignments/:id/toggle
|
||||
func (h *APIHandler) ToggleAssignment(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignment ID"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment, err := h.assignmentService.ToggleComplete(userID, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Assignment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, assignment)
|
||||
}
|
||||
233
internal/handler/assignment_handler.go
Normal file
233
internal/handler/assignment_handler.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AssignmentHandler struct {
|
||||
assignmentService *service.AssignmentService
|
||||
}
|
||||
|
||||
func NewAssignmentHandler() *AssignmentHandler {
|
||||
return &AssignmentHandler{
|
||||
assignmentService: service.NewAssignmentService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) getUserID(c *gin.Context) uint {
|
||||
userID, _ := c.Get(middleware.UserIDKey)
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Dashboard(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
stats, _ := h.assignmentService.GetDashboardStats(userID)
|
||||
dueToday, _ := h.assignmentService.GetDueTodayByUser(userID)
|
||||
overdue, _ := h.assignmentService.GetOverdueByUser(userID)
|
||||
upcoming, _ := h.assignmentService.GetDueThisWeekByUser(userID)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "dashboard.html", gin.H{
|
||||
"title": "ダッシュボード",
|
||||
"stats": stats,
|
||||
"dueToday": dueToday,
|
||||
"overdue": overdue,
|
||||
"upcoming": upcoming,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Index(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
filter := c.Query("filter")
|
||||
filter = strings.TrimSpace(filter)
|
||||
if filter == "" {
|
||||
filter = "pending"
|
||||
}
|
||||
query := c.Query("q")
|
||||
priority := c.Query("priority")
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
page = 1
|
||||
}
|
||||
const pageSize = 10
|
||||
|
||||
result, err := h.assignmentService.SearchAssignments(userID, query, priority, filter, page, pageSize)
|
||||
|
||||
var assignments []models.Assignment
|
||||
var totalPages, currentPage int
|
||||
if err != nil || result == nil {
|
||||
assignments = []models.Assignment{}
|
||||
totalPages = 1
|
||||
currentPage = 1
|
||||
} else {
|
||||
assignments = result.Assignments
|
||||
totalPages = result.TotalPages
|
||||
currentPage = result.CurrentPage
|
||||
}
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "assignments/index.html", gin.H{
|
||||
"title": "課題一覧",
|
||||
"assignments": assignments,
|
||||
"filter": filter,
|
||||
"query": query,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
"currentPage": currentPage,
|
||||
"totalPages": totalPages,
|
||||
"hasPrev": currentPage > 1,
|
||||
"hasNext": currentPage < totalPages,
|
||||
"prevPage": currentPage - 1,
|
||||
"nextPage": currentPage + 1,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) New(c *gin.Context) {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Create(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
|
||||
title := c.PostForm("title")
|
||||
description := c.PostForm("description")
|
||||
subject := c.PostForm("subject")
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": "提出期限の形式が正しくありません",
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Create(userID, title, description, subject, priority, dueDate)
|
||||
if err != nil {
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
RenderHTML(c, http.StatusOK, "assignments/new.html", gin.H{
|
||||
"title": "課題登録",
|
||||
"error": "課題の登録に失敗しました",
|
||||
"formTitle": title,
|
||||
"description": description,
|
||||
"subject": subject,
|
||||
"priority": priority,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Edit(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
assignment, err := h.assignmentService.GetByID(userID, uint(id))
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "assignments/edit.html", gin.H{
|
||||
"title": "課題編集",
|
||||
"assignment": assignment,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Update(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
title := c.PostForm("title")
|
||||
description := c.PostForm("description")
|
||||
subject := c.PostForm("subject")
|
||||
priority := c.PostForm("priority")
|
||||
dueDateStr := c.PostForm("due_date")
|
||||
|
||||
dueDate, err := time.ParseInLocation("2006-01-02T15:04", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
dueDate, err = time.ParseInLocation("2006-01-02", dueDateStr, time.Local)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
dueDate = dueDate.Add(23*time.Hour + 59*time.Minute)
|
||||
}
|
||||
|
||||
_, err = h.assignmentService.Update(userID, uint(id), title, description, subject, priority, dueDate)
|
||||
if err != nil {
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Toggle(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
h.assignmentService.ToggleComplete(userID, uint(id))
|
||||
|
||||
referer := c.Request.Referer()
|
||||
if referer == "" {
|
||||
referer = "/assignments"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
func (h *AssignmentHandler) Delete(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
h.assignmentService.Delete(userID, uint(id))
|
||||
|
||||
c.Redirect(http.StatusFound, "/assignments")
|
||||
}
|
||||
114
internal/handler/auth_handler.go
Normal file
114
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler() *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: service.NewAuthService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
|
||||
user, err := h.authService.Login(email, password)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "login.html", gin.H{
|
||||
"title": "ログイン",
|
||||
"error": "メールアドレスまたはパスワードが正しくありません",
|
||||
"email": email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
session.Set(middleware.UserIDKey, user.ID)
|
||||
session.Set(middleware.UserRoleKey, user.Role)
|
||||
session.Set(middleware.UserNameKey, user.Name)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ShowRegister(c *gin.Context) {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
email := c.PostForm("email")
|
||||
password := c.PostForm("password")
|
||||
passwordConfirm := c.PostForm("password_confirm")
|
||||
name := c.PostForm("name")
|
||||
|
||||
if password != passwordConfirm {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードが一致しません",
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": "パスワードは8文字以上で入力してください",
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Register(email, password, name)
|
||||
if err != nil {
|
||||
errorMsg := "登録に失敗しました"
|
||||
if err == service.ErrEmailAlreadyExists {
|
||||
errorMsg = "このメールアドレスは既に使用されています"
|
||||
}
|
||||
RenderHTML(c, http.StatusOK, "register.html", gin.H{
|
||||
"title": "新規登録",
|
||||
"error": errorMsg,
|
||||
"email": email,
|
||||
"name": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session := sessions.Default(c)
|
||||
session.Set(middleware.UserIDKey, user.ID)
|
||||
session.Set(middleware.UserRoleKey, user.Role)
|
||||
session.Set(middleware.UserNameKey, user.Name)
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Clear()
|
||||
session.Save()
|
||||
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
33
internal/handler/helper.go
Normal file
33
internal/handler/helper.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const csrfTokenKey = "csrf_token"
|
||||
const csrfTokenFormKey = "_csrf"
|
||||
|
||||
func RenderHTML(c *gin.Context, code int, name string, obj gin.H) {
|
||||
if obj == nil {
|
||||
obj = gin.H{}
|
||||
}
|
||||
|
||||
if startTime, exists := c.Get("startTime"); exists {
|
||||
duration := time.Since(startTime.(time.Time))
|
||||
obj["processing_time"] = fmt.Sprintf("%.2fms", float64(duration.Microseconds())/1000.0)
|
||||
} else {
|
||||
obj["processing_time"] = "unknown"
|
||||
}
|
||||
|
||||
if token, exists := c.Get(csrfTokenKey); exists {
|
||||
obj["csrfToken"] = token.(string)
|
||||
obj["csrfField"] = template.HTML(`<input type="hidden" name="` + csrfTokenFormKey + `" value="` + token.(string) + `">`)
|
||||
}
|
||||
|
||||
c.HTML(code, name, obj)
|
||||
}
|
||||
|
||||
122
internal/handler/profile_handler.go
Normal file
122
internal/handler/profile_handler.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ProfileHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewProfileHandler() *ProfileHandler {
|
||||
return &ProfileHandler{
|
||||
authService: service.NewAuthService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) getUserID(c *gin.Context) uint {
|
||||
userID, _ := c.Get(middleware.UserIDKey)
|
||||
return userID.(uint)
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) Show(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) Update(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
name := c.PostForm("name")
|
||||
|
||||
err := h.authService.UpdateProfile(userID, name)
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"error": "プロフィールの更新に失敗しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"success": "プロフィールを更新しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": user.Name,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *ProfileHandler) ChangePassword(c *gin.Context) {
|
||||
userID := h.getUserID(c)
|
||||
oldPassword := c.PostForm("old_password")
|
||||
newPassword := c.PostForm("new_password")
|
||||
confirmPassword := c.PostForm("confirm_password")
|
||||
|
||||
role, _ := c.Get(middleware.UserRoleKey)
|
||||
name, _ := c.Get(middleware.UserNameKey)
|
||||
user, _ := h.authService.GetUserByID(userID)
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "新しいパスワードが一致しません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(newPassword) < 8 {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "パスワードは8文字以上で入力してください",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.authService.ChangePassword(userID, oldPassword, newPassword)
|
||||
if err != nil {
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordError": "現在のパスワードが正しくありません",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
RenderHTML(c, http.StatusOK, "profile.html", gin.H{
|
||||
"title": "プロフィール",
|
||||
"user": user,
|
||||
"passwordSuccess": "パスワードを変更しました",
|
||||
"isAdmin": role == "admin",
|
||||
"userName": name,
|
||||
})
|
||||
}
|
||||
121
internal/middleware/auth.go
Normal file
121
internal/middleware/auth.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const UserIDKey = "user_id"
|
||||
const UserRoleKey = "user_role"
|
||||
const UserNameKey = "user_name"
|
||||
|
||||
func AuthRequired(authService *service.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get(UserIDKey)
|
||||
|
||||
if userID == nil {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := authService.GetUserByID(userID.(uint))
|
||||
if err != nil {
|
||||
session.Clear()
|
||||
session.Save()
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(UserIDKey, user.ID)
|
||||
c.Set(UserRoleKey, user.Role)
|
||||
c.Set(UserNameKey, user.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, exists := c.Get(UserRoleKey)
|
||||
if !exists || role != "admin" {
|
||||
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||
"title": "アクセス拒否",
|
||||
"message": "この操作には管理者権限が必要です。",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GuestOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get(UserIDKey)
|
||||
|
||||
if userID != nil {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func InjectUserInfo() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
userID := session.Get(UserIDKey)
|
||||
|
||||
if userID != nil {
|
||||
c.Set(UserIDKey, userID.(uint))
|
||||
c.Set(UserRoleKey, session.Get(UserRoleKey))
|
||||
c.Set(UserNameKey, session.Get(UserNameKey))
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
type APIKeyValidator interface {
|
||||
ValidateAPIKey(key string) (uint, error)
|
||||
}
|
||||
|
||||
func APIKeyAuth(validator APIKeyValidator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
if len(authHeader) <= len(bearerPrefix) || authHeader[:len(bearerPrefix)] != bearerPrefix {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization format. Use: Bearer <api_key>"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := authHeader[len(bearerPrefix):]
|
||||
|
||||
userID, err := validator.ValidateAPIKey(apiKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(UserIDKey, userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
119
internal/middleware/csrf.go
Normal file
119
internal/middleware/csrf.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
csrfTokenKey = "csrf_token"
|
||||
csrfTokenFormKey = "_csrf"
|
||||
csrfTokenHeader = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type CSRFConfig struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
func generateCSRFToken(secret string) (string, error) {
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write(randomBytes)
|
||||
signature := h.Sum(nil)
|
||||
|
||||
token := append(randomBytes, signature...)
|
||||
return base64.URLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
func validateCSRFToken(token, secret string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
decoded, err := base64.URLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(decoded) != 64 {
|
||||
return false
|
||||
}
|
||||
|
||||
randomBytes := decoded[:32]
|
||||
providedSignature := decoded[32:]
|
||||
|
||||
h := hmac.New(sha256.New, []byte(secret))
|
||||
h.Write(randomBytes)
|
||||
expectedSignature := h.Sum(nil)
|
||||
|
||||
return hmac.Equal(providedSignature, expectedSignature)
|
||||
}
|
||||
|
||||
func CSRF(config CSRFConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
|
||||
csrfToken, ok := session.Get(csrfTokenKey).(string)
|
||||
if !ok || csrfToken == "" || !validateCSRFToken(csrfToken, config.Secret) {
|
||||
newToken, err := generateCSRFToken(config.Secret)
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
csrfToken = newToken
|
||||
session.Set(csrfTokenKey, csrfToken)
|
||||
session.Save()
|
||||
}
|
||||
|
||||
c.Set(csrfTokenKey, csrfToken)
|
||||
|
||||
method := strings.ToUpper(c.Request.Method)
|
||||
if method == "GET" || method == "HEAD" || method == "OPTIONS" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
submittedToken := c.PostForm(csrfTokenFormKey)
|
||||
if submittedToken == "" {
|
||||
submittedToken = c.GetHeader(csrfTokenHeader)
|
||||
}
|
||||
|
||||
sessionToken := session.Get(csrfTokenKey)
|
||||
if sessionToken == nil || submittedToken != sessionToken.(string) {
|
||||
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||
"title": "CSRFエラー",
|
||||
"message": "不正なリクエストです。ページを再読み込みしてください。",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
|
||||
newToken, err := generateCSRFToken(config.Secret)
|
||||
if err == nil {
|
||||
session.Set(csrfTokenKey, newToken)
|
||||
session.Save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CSRFField(c *gin.Context) template.HTML {
|
||||
token, exists := c.Get(csrfTokenKey)
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
return template.HTML(`<input type="hidden" name="` + csrfTokenFormKey + `" value="` + token.(string) + `">`)
|
||||
}
|
||||
94
internal/middleware/ratelimit.go
Normal file
94
internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RateLimitConfig struct {
|
||||
Enabled bool
|
||||
Requests int
|
||||
Window int
|
||||
}
|
||||
|
||||
type rateLimitEntry struct {
|
||||
count int
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type rateLimiter struct {
|
||||
entries map[string]*rateLimitEntry
|
||||
mu sync.Mutex
|
||||
config RateLimitConfig
|
||||
}
|
||||
|
||||
func newRateLimiter(config RateLimitConfig) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
entries: make(map[string]*rateLimitEntry),
|
||||
config: config,
|
||||
}
|
||||
|
||||
go rl.cleanup()
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for key, entry := range rl.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(rl.entries, key)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
entry, exists := rl.entries[key]
|
||||
|
||||
if !exists || now.After(entry.expiresAt) {
|
||||
rl.entries[key] = &rateLimitEntry{
|
||||
count: 1,
|
||||
expiresAt: now.Add(time.Duration(rl.config.Window) * time.Second),
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return entry.count <= rl.config.Requests
|
||||
}
|
||||
|
||||
func RateLimit(config RateLimitConfig) gin.HandlerFunc {
|
||||
if !config.Enabled {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
limiter := newRateLimiter(config)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
if !limiter.allow(clientIP) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "リクエスト数が制限を超えました。しばらくしてからお試しください。",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
52
internal/middleware/security.go
Normal file
52
internal/middleware/security.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SecurityConfig struct {
|
||||
HTTPS bool
|
||||
}
|
||||
func SecurityHeaders(config SecurityConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if config.HTTPS {
|
||||
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
|
||||
csp := []string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
|
||||
"font-src 'self' https://cdn.jsdelivr.net",
|
||||
"img-src 'self' data:",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}
|
||||
c.Header("Content-Security-Policy", strings.Join(csp, "; "))
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ForceHTTPS(config SecurityConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if config.HTTPS && c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
|
||||
|
||||
host := c.Request.Host
|
||||
target := "https://" + host + c.Request.URL.Path
|
||||
if len(c.Request.URL.RawQuery) > 0 {
|
||||
target += "?" + c.Request.URL.RawQuery
|
||||
}
|
||||
c.Redirect(301, target)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
14
internal/middleware/timer.go
Normal file
14
internal/middleware/timer.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RequestTimer() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("startTime", time.Now())
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
19
internal/models/api_key.go
Normal file
19
internal/models/api_key.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type APIKey struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
KeyHash string `gorm:"not null;uniqueIndex" json:"-"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
41
internal/models/assignment.go
Normal file
41
internal/models/assignment.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Assignment struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Description string `json:"description"`
|
||||
Subject string `json:"subject"`
|
||||
Priority string `gorm:"not null;default:medium" json:"priority"` // low, medium, high
|
||||
DueDate time.Time `gorm:"not null" json:"due_date"`
|
||||
IsCompleted bool `gorm:"default:false" json:"is_completed"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Assignment) IsOverdue() bool {
|
||||
return !a.IsCompleted && time.Now().After(a.DueDate)
|
||||
}
|
||||
|
||||
func (a *Assignment) IsDueToday() bool {
|
||||
now := time.Now()
|
||||
return a.DueDate.Year() == now.Year() &&
|
||||
a.DueDate.Month() == now.Month() &&
|
||||
a.DueDate.Day() == now.Day()
|
||||
}
|
||||
|
||||
func (a *Assignment) IsDueThisWeek() bool {
|
||||
now := time.Now()
|
||||
weekLater := now.AddDate(0, 0, 7)
|
||||
return a.DueDate.After(now) && a.DueDate.Before(weekLater)
|
||||
}
|
||||
28
internal/models/user.go
Normal file
28
internal/models/user.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Role string `gorm:"not null;default:user" json:"role"` // "admin" or "user"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Assignments []Assignment `gorm:"foreignKey:UserID" json:"assignments,omitempty"`
|
||||
}
|
||||
|
||||
func (u *User) IsAdmin() bool {
|
||||
return u.Role == "admin"
|
||||
}
|
||||
|
||||
func (u *User) GetID() uint {
|
||||
return u.ID
|
||||
}
|
||||
188
internal/repository/assignment_repository.go
Normal file
188
internal/repository/assignment_repository.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AssignmentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAssignmentRepository() *AssignmentRepository {
|
||||
return &AssignmentRepository{db: database.GetDB()}
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) Create(assignment *models.Assignment) error {
|
||||
return r.db.Create(assignment).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindByID(id uint) (*models.Assignment, error) {
|
||||
var assignment models.Assignment
|
||||
err := r.db.First(&assignment, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindByUserID(userID uint) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
err := r.db.Where("user_id = ?", userID).Order("due_date ASC").Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindPendingByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
query := r.db.Where("user_id = ? AND is_completed = ?", userID, false).
|
||||
Order("due_date ASC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := query.Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindCompletedByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||
var assignments []models.Assignment
|
||||
query := r.db.Where("user_id = ? AND is_completed = ?", userID, true).
|
||||
Order("completed_at DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := query.Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindDueTodayByUserID(userID uint) ([]models.Assignment, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
endOfDay := startOfDay.AddDate(0, 0, 1)
|
||||
|
||||
var assignments []models.Assignment
|
||||
err := r.db.Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, endOfDay).
|
||||
Order("due_date ASC").Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindDueThisWeekByUserID(userID uint) ([]models.Assignment, error) {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
weekLater := startOfDay.AddDate(0, 0, 7)
|
||||
|
||||
var assignments []models.Assignment
|
||||
err := r.db.Where("user_id = ? AND is_completed = ? AND due_date >= ? AND due_date < ?",
|
||||
userID, false, startOfDay, weekLater).
|
||||
Order("due_date ASC").Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) FindOverdueByUserID(userID uint, limit, offset int) ([]models.Assignment, error) {
|
||||
now := time.Now()
|
||||
|
||||
var assignments []models.Assignment
|
||||
query := r.db.Where("user_id = ? AND is_completed = ? AND due_date < ?",
|
||||
userID, false, now).
|
||||
Order("due_date ASC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit).Offset(offset)
|
||||
}
|
||||
err := query.Find(&assignments).Error
|
||||
return assignments, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) Update(assignment *models.Assignment) error {
|
||||
return r.db.Save(assignment).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.Assignment{}, id).Error
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountPendingByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ?", userID, false).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) GetSubjectsByUserID(userID uint) ([]string, error) {
|
||||
var subjects []string
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND subject != ''", userID).
|
||||
Distinct("subject").
|
||||
Pluck("subject", &subjects).Error
|
||||
return subjects, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountCompletedByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ?", userID, true).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) Search(userID uint, queryStr, priority, filter string, page, pageSize int) ([]models.Assignment, int64, error) {
|
||||
var assignments []models.Assignment
|
||||
var totalCount int64
|
||||
|
||||
dbQuery := r.db.Model(&models.Assignment{}).Where("user_id = ?", userID)
|
||||
|
||||
if queryStr != "" {
|
||||
dbQuery = dbQuery.Where("title LIKE ? OR description LIKE ?", "%"+queryStr+"%", "%"+queryStr+"%")
|
||||
}
|
||||
|
||||
if priority != "" {
|
||||
dbQuery = dbQuery.Where("priority = ?", priority)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch filter {
|
||||
case "completed":
|
||||
dbQuery = dbQuery.Where("is_completed = ?", true)
|
||||
case "overdue":
|
||||
dbQuery = dbQuery.Where("is_completed = ? AND due_date < ?", false, now)
|
||||
default: // pending
|
||||
dbQuery = dbQuery.Where("is_completed = ?", false)
|
||||
}
|
||||
|
||||
if err := dbQuery.Count(&totalCount).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if filter == "completed" {
|
||||
dbQuery = dbQuery.Order("completed_at DESC")
|
||||
} else {
|
||||
dbQuery = dbQuery.Order("due_date ASC")
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
err := dbQuery.Limit(pageSize).Offset(offset).Find(&assignments).Error
|
||||
return assignments, totalCount, err
|
||||
}
|
||||
|
||||
func (r *AssignmentRepository) CountOverdueByUserID(userID uint) (int64, error) {
|
||||
var count int64
|
||||
now := time.Now()
|
||||
err := r.db.Model(&models.Assignment{}).
|
||||
Where("user_id = ? AND is_completed = ? AND due_date < ?", userID, false, now).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
61
internal/repository/user_repository.go
Normal file
61
internal/repository/user_repository.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository() *UserRepository {
|
||||
return &UserRepository{db: database.GetDB()}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Create(user *models.User) error {
|
||||
return r.db.Create(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByEmail(email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindAll() ([]models.User, error) {
|
||||
var users []models.User
|
||||
err := r.db.Find(&users).Error
|
||||
return users, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) error {
|
||||
return r.db.Save(user).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(id uint) error {
|
||||
if err := r.db.Unscoped().Where("user_id = ?", id).Delete(&models.Assignment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return r.db.Unscoped().Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *UserRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.User{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
241
internal/router/router.go
Normal file
241
internal/router/router.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/config"
|
||||
"homework-manager/internal/handler"
|
||||
"homework-manager/internal/middleware"
|
||||
"homework-manager/internal/service"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func getFuncMap() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"formatDate": func(t time.Time) string {
|
||||
return t.Format("2006/01/02")
|
||||
},
|
||||
"formatDateTime": func(t time.Time) string {
|
||||
return t.Format("2006/01/02 15:04")
|
||||
},
|
||||
"formatDateInput": func(t time.Time) string {
|
||||
return t.Format("2006-01-02T15:04")
|
||||
},
|
||||
"isOverdue": func(t time.Time, completed bool) bool {
|
||||
return !completed && time.Now().After(t)
|
||||
},
|
||||
"daysUntil": func(t time.Time) int {
|
||||
return int(time.Until(t).Hours() / 24)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadTemplates() (*template.Template, error) {
|
||||
tmpl := template.New("").Funcs(getFuncMap())
|
||||
|
||||
baseContent, err := os.ReadFile("web/templates/layouts/base.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templateDirs := []struct {
|
||||
pattern string
|
||||
prefix string
|
||||
}{
|
||||
{"web/templates/auth/*.html", ""},
|
||||
{"web/templates/pages/*.html", ""},
|
||||
{"web/templates/assignments/*.html", "assignments/"},
|
||||
{"web/templates/admin/*.html", "admin/"},
|
||||
}
|
||||
|
||||
for _, dir := range templateDirs {
|
||||
files, err := filepath.Glob(dir.pattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range files {
|
||||
name := dir.prefix + filepath.Base(file)
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reDefine := regexp.MustCompile(`{{\s*define\s+"([^"]+)"\s*}}`)
|
||||
reTemplate := regexp.MustCompile(`{{\s*template\s+"([^"]+)"\s*([^}]*)\s*}}`)
|
||||
|
||||
uniqueBase := reDefine.ReplaceAllStringFunc(string(baseContent), func(m string) string {
|
||||
match := reDefine.FindStringSubmatch(m)
|
||||
blockName := match[1]
|
||||
if blockName == "head" || blockName == "scripts" || blockName == "content" || blockName == "base" {
|
||||
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
uniqueBase = reTemplate.ReplaceAllStringFunc(uniqueBase, func(m string) string {
|
||||
match := reTemplate.FindStringSubmatch(m)
|
||||
blockName := match[1]
|
||||
if blockName == "head" || blockName == "scripts" || blockName == "content" || blockName == "base" {
|
||||
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||
}
|
||||
return m
|
||||
})
|
||||
uniqueContent := reDefine.ReplaceAllStringFunc(string(content), func(m string) string {
|
||||
match := reDefine.FindStringSubmatch(m)
|
||||
blockName := match[1]
|
||||
if blockName == "head" || blockName == "scripts" || blockName == "content" {
|
||||
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
uniqueContent = reTemplate.ReplaceAllStringFunc(uniqueContent, func(m string) string {
|
||||
match := reTemplate.FindStringSubmatch(m)
|
||||
blockName := match[1]
|
||||
if blockName == "base" {
|
||||
return strings.Replace(m, blockName, name+"_"+blockName, 1)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
combined := uniqueBase + "\n" + uniqueContent
|
||||
_, err = tmpl.New(name).Parse(combined)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func Setup(cfg *config.Config) *gin.Engine {
|
||||
if !cfg.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
if len(cfg.TrustedProxies) > 0 {
|
||||
r.SetTrustedProxies(cfg.TrustedProxies)
|
||||
}
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
panic("Failed to load templates: " + err.Error())
|
||||
}
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
|
||||
r.Static("/static", "web/static")
|
||||
|
||||
store := cookie.NewStore([]byte(cfg.SessionSecret))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
HttpOnly: true,
|
||||
Secure: cfg.HTTPS,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
r.Use(sessions.Sessions("session", store))
|
||||
r.Use(middleware.RequestTimer())
|
||||
|
||||
securityConfig := middleware.SecurityConfig{
|
||||
HTTPS: cfg.HTTPS,
|
||||
}
|
||||
r.Use(middleware.SecurityHeaders(securityConfig))
|
||||
r.Use(middleware.ForceHTTPS(securityConfig))
|
||||
|
||||
r.Use(middleware.RateLimit(middleware.RateLimitConfig{
|
||||
Enabled: cfg.RateLimitEnabled,
|
||||
Requests: cfg.RateLimitRequests,
|
||||
Window: cfg.RateLimitWindow,
|
||||
}))
|
||||
|
||||
csrfMiddleware := middleware.CSRF(middleware.CSRFConfig{
|
||||
Secret: cfg.CSRFSecret,
|
||||
})
|
||||
|
||||
authService := service.NewAuthService()
|
||||
apiKeyService := service.NewAPIKeyService()
|
||||
|
||||
authHandler := handler.NewAuthHandler()
|
||||
assignmentHandler := handler.NewAssignmentHandler()
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
profileHandler := handler.NewProfileHandler()
|
||||
apiHandler := handler.NewAPIHandler()
|
||||
|
||||
guest := r.Group("/")
|
||||
guest.Use(middleware.GuestOnly())
|
||||
guest.Use(csrfMiddleware)
|
||||
{
|
||||
guest.GET("/login", authHandler.ShowLogin)
|
||||
guest.POST("/login", authHandler.Login)
|
||||
if cfg.AllowRegistration {
|
||||
guest.GET("/register", authHandler.ShowRegister)
|
||||
guest.POST("/register", authHandler.Register)
|
||||
} else {
|
||||
guest.GET("/register", func(c *gin.Context) {
|
||||
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
||||
"title": "登録無効",
|
||||
"message": "新規登録は現在受け付けておりません。",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
auth := r.Group("/")
|
||||
auth.Use(middleware.AuthRequired(authService))
|
||||
auth.Use(csrfMiddleware)
|
||||
{
|
||||
auth.GET("/", assignmentHandler.Dashboard)
|
||||
auth.POST("/logout", authHandler.Logout)
|
||||
|
||||
auth.GET("/assignments", assignmentHandler.Index)
|
||||
auth.GET("/assignments/new", assignmentHandler.New)
|
||||
auth.POST("/assignments", assignmentHandler.Create)
|
||||
auth.GET("/assignments/:id/edit", assignmentHandler.Edit)
|
||||
auth.POST("/assignments/:id", assignmentHandler.Update)
|
||||
auth.POST("/assignments/:id/toggle", assignmentHandler.Toggle)
|
||||
auth.POST("/assignments/:id/delete", assignmentHandler.Delete)
|
||||
|
||||
auth.GET("/profile", profileHandler.Show)
|
||||
auth.POST("/profile", profileHandler.Update)
|
||||
auth.POST("/profile/password", profileHandler.ChangePassword)
|
||||
admin := auth.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("/users", adminHandler.Index)
|
||||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||
admin.POST("/users/:id/role", adminHandler.ChangeRole)
|
||||
|
||||
admin.GET("/api-keys", adminHandler.APIKeys)
|
||||
admin.POST("/api-keys", adminHandler.CreateAPIKey)
|
||||
admin.POST("/api-keys/:id/delete", adminHandler.DeleteAPIKey)
|
||||
}
|
||||
}
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.APIKeyAuth(apiKeyService))
|
||||
{
|
||||
api.GET("/assignments", apiHandler.ListAssignments)
|
||||
api.GET("/assignments/pending", apiHandler.ListPendingAssignments)
|
||||
api.GET("/assignments/completed", apiHandler.ListCompletedAssignments)
|
||||
api.GET("/assignments/overdue", apiHandler.ListOverdueAssignments)
|
||||
api.GET("/assignments/due-today", apiHandler.ListDueTodayAssignments)
|
||||
api.GET("/assignments/due-this-week", apiHandler.ListDueThisWeekAssignments)
|
||||
api.GET("/assignments/:id", apiHandler.GetAssignment)
|
||||
api.POST("/assignments", apiHandler.CreateAssignment)
|
||||
api.PUT("/assignments/:id", apiHandler.UpdateAssignment)
|
||||
api.DELETE("/assignments/:id", apiHandler.DeleteAssignment)
|
||||
api.PATCH("/assignments/:id/toggle", apiHandler.ToggleAssignment)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
62
internal/service/admin_service.go
Normal file
62
internal/service/admin_service.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotDeleteSelf = errors.New("cannot delete yourself")
|
||||
ErrCannotChangeSelfRole = errors.New("cannot change your own role")
|
||||
)
|
||||
|
||||
type AdminService struct {
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAdminService() *AdminService {
|
||||
return &AdminService{
|
||||
userRepo: repository.NewUserRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminService) GetAllUsers() ([]models.User, error) {
|
||||
return s.userRepo.FindAll()
|
||||
}
|
||||
|
||||
func (s *AdminService) GetUserByID(id uint) (*models.User, error) {
|
||||
return s.userRepo.FindByID(id)
|
||||
}
|
||||
|
||||
func (s *AdminService) DeleteUser(adminID, targetID uint) error {
|
||||
if adminID == targetID {
|
||||
return ErrCannotDeleteSelf
|
||||
}
|
||||
|
||||
_, err := s.userRepo.FindByID(targetID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
return s.userRepo.Delete(targetID)
|
||||
}
|
||||
|
||||
func (s *AdminService) ChangeRole(adminID, targetID uint, newRole string) error {
|
||||
if adminID == targetID {
|
||||
return ErrCannotChangeSelfRole
|
||||
}
|
||||
|
||||
if newRole != "admin" && newRole != "user" {
|
||||
return errors.New("invalid role")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(targetID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
user.Role = newRole
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
89
internal/service/api_key_service.go
Normal file
89
internal/service/api_key_service.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/database"
|
||||
"homework-manager/internal/models"
|
||||
)
|
||||
|
||||
type APIKeyService struct{}
|
||||
|
||||
func NewAPIKeyService() *APIKeyService {
|
||||
return &APIKeyService{}
|
||||
}
|
||||
|
||||
|
||||
func (s *APIKeyService) generateRandomKey() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "hm_" + hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) hashKey(key string) string {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (s *APIKeyService) CreateAPIKey(userID uint, name string) (string, *models.APIKey, error) {
|
||||
if name == "" {
|
||||
return "", nil, errors.New("キー名を入力してください")
|
||||
}
|
||||
|
||||
plainKey, err := s.generateRandomKey()
|
||||
if err != nil {
|
||||
return "", nil, errors.New("キーの生成に失敗しました")
|
||||
}
|
||||
|
||||
apiKey := &models.APIKey{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
KeyHash: s.hashKey(plainKey),
|
||||
}
|
||||
|
||||
if err := database.GetDB().Create(apiKey).Error; err != nil {
|
||||
return "", nil, errors.New("キーの保存に失敗しました")
|
||||
}
|
||||
|
||||
return plainKey, apiKey, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) ValidateAPIKey(plainKey string) (uint, error) {
|
||||
hash := s.hashKey(plainKey)
|
||||
|
||||
var apiKey models.APIKey
|
||||
if err := database.GetDB().Where("key_hash = ?", hash).First(&apiKey).Error; err != nil {
|
||||
return 0, errors.New("無効なAPIキーです")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
database.GetDB().Model(&apiKey).Update("last_used", now)
|
||||
|
||||
return apiKey.UserID, nil
|
||||
}
|
||||
|
||||
func (s *APIKeyService) GetAllAPIKeys() ([]models.APIKey, error) {
|
||||
var keys []models.APIKey
|
||||
err := database.GetDB().Preload("User").Order("created_at desc").Find(&keys).Error
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (s *APIKeyService) GetAPIKeysByUser(userID uint) ([]models.APIKey, error) {
|
||||
var keys []models.APIKey
|
||||
err := database.GetDB().Where("user_id = ?", userID).Order("created_at desc").Find(&keys).Error
|
||||
return keys, err
|
||||
}
|
||||
|
||||
func (s *APIKeyService) DeleteAPIKey(id uint) error {
|
||||
result := database.GetDB().Delete(&models.APIKey{}, id)
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New("APIキーが見つかりません")
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
269
internal/service/assignment_service.go
Normal file
269
internal/service/assignment_service.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAssignmentNotFound = errors.New("assignment not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
)
|
||||
|
||||
type PaginatedResult struct {
|
||||
Assignments []models.Assignment
|
||||
TotalCount int64
|
||||
TotalPages int
|
||||
CurrentPage int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type AssignmentService struct {
|
||||
assignmentRepo *repository.AssignmentRepository
|
||||
}
|
||||
|
||||
func NewAssignmentService() *AssignmentService {
|
||||
return &AssignmentService{
|
||||
assignmentRepo: repository.NewAssignmentRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Create(userID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||
if priority == "" {
|
||||
priority = "medium"
|
||||
}
|
||||
assignment := &models.Assignment{
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Subject: subject,
|
||||
Priority: priority,
|
||||
DueDate: dueDate,
|
||||
IsCompleted: false,
|
||||
}
|
||||
|
||||
if err := s.assignmentRepo.Create(assignment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assignment, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetByID(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
assignment, err := s.assignmentRepo.FindByID(assignmentID)
|
||||
if err != nil {
|
||||
return nil, ErrAssignmentNotFound
|
||||
}
|
||||
|
||||
if assignment.UserID != userID {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
|
||||
return assignment, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetAllByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetPendingByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindPendingByUserID(userID, 0, 0)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetPendingByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
assignments, err := s.assignmentRepo.FindPendingByUserID(userID, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalCount, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
return &PaginatedResult{
|
||||
Assignments: assignments,
|
||||
TotalCount: totalCount,
|
||||
TotalPages: totalPages,
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetCompletedByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindCompletedByUserID(userID, 0, 0)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetCompletedByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
assignments, err := s.assignmentRepo.FindCompletedByUserID(userID, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalCount, _ := s.assignmentRepo.CountCompletedByUserID(userID)
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
return &PaginatedResult{
|
||||
Assignments: assignments,
|
||||
TotalCount: totalCount,
|
||||
TotalPages: totalPages,
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetDueTodayByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindDueTodayByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetDueThisWeekByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindDueThisWeekByUserID(userID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetOverdueByUser(userID uint) ([]models.Assignment, error) {
|
||||
return s.assignmentRepo.FindOverdueByUserID(userID, 0, 0)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetOverdueByUserPaginated(userID uint, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
assignments, err := s.assignmentRepo.FindOverdueByUserID(userID, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalCount, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
return &PaginatedResult{
|
||||
Assignments: assignments,
|
||||
TotalCount: totalCount,
|
||||
TotalPages: totalPages,
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) SearchAssignments(userID uint, query, priority, filter string, page, pageSize int) (*PaginatedResult, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
assignments, totalCount, err := s.assignmentRepo.Search(userID, query, priority, filter, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
return &PaginatedResult{
|
||||
Assignments: assignments,
|
||||
TotalCount: totalCount,
|
||||
TotalPages: totalPages,
|
||||
CurrentPage: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Update(userID, assignmentID uint, title, description, subject, priority string, dueDate time.Time) (*models.Assignment, error) {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assignment.Title = title
|
||||
assignment.Description = description
|
||||
assignment.Subject = subject
|
||||
assignment.Priority = priority
|
||||
assignment.DueDate = dueDate
|
||||
|
||||
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assignment, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) ToggleComplete(userID, assignmentID uint) (*models.Assignment, error) {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assignment.IsCompleted = !assignment.IsCompleted
|
||||
if assignment.IsCompleted {
|
||||
now := time.Now()
|
||||
assignment.CompletedAt = &now
|
||||
} else {
|
||||
assignment.CompletedAt = nil
|
||||
}
|
||||
|
||||
if err := s.assignmentRepo.Update(assignment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return assignment, nil
|
||||
}
|
||||
|
||||
func (s *AssignmentService) Delete(userID, assignmentID uint) error {
|
||||
assignment, err := s.GetByID(userID, assignmentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.assignmentRepo.Delete(assignment.ID)
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetSubjectsByUser(userID uint) ([]string, error) {
|
||||
return s.assignmentRepo.GetSubjectsByUserID(userID)
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalPending int64
|
||||
DueToday int
|
||||
DueThisWeek int
|
||||
Overdue int
|
||||
Subjects []string
|
||||
}
|
||||
|
||||
func (s *AssignmentService) GetDashboardStats(userID uint) (*DashboardStats, error) {
|
||||
pending, _ := s.assignmentRepo.CountPendingByUserID(userID)
|
||||
dueToday, _ := s.assignmentRepo.FindDueTodayByUserID(userID)
|
||||
dueThisWeek, _ := s.assignmentRepo.FindDueThisWeekByUserID(userID)
|
||||
overdueCount, _ := s.assignmentRepo.CountOverdueByUserID(userID)
|
||||
subjects, _ := s.assignmentRepo.GetSubjectsByUserID(userID)
|
||||
|
||||
return &DashboardStats{
|
||||
TotalPending: pending,
|
||||
DueToday: len(dueToday),
|
||||
DueThisWeek: len(dueThisWeek),
|
||||
Overdue: int(overdueCount),
|
||||
Subjects: subjects,
|
||||
}, nil
|
||||
}
|
||||
106
internal/service/auth_service.go
Normal file
106
internal/service/auth_service.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"homework-manager/internal/models"
|
||||
"homework-manager/internal/repository"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrEmailAlreadyExists = errors.New("email already exists")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepository
|
||||
}
|
||||
|
||||
func NewAuthService() *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: repository.NewUserRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(email, password, name string) (*models.User, error) {
|
||||
// Check if email already exists
|
||||
_, err := s.userRepo.FindByEmail(email)
|
||||
if err == nil {
|
||||
return nil, ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine role (first user is admin)
|
||||
role := "user"
|
||||
count, _ := s.userRepo.Count()
|
||||
if count == 0 {
|
||||
role = "admin"
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
Email: email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: name,
|
||||
Role: role,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(email, password string) (*models.User, error) {
|
||||
user, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
|
||||
return s.userRepo.FindByID(id)
|
||||
}
|
||||
|
||||
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(oldPassword)); err != nil {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateProfile(userID uint, name string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
return s.userRepo.Update(user)
|
||||
}
|
||||
280
web/static/css/style.css
Normal file
280
web/static/css/style.css
Normal file
@@ -0,0 +1,280 @@
|
||||
/* Custom styles for Homework Manager */
|
||||
|
||||
:root {
|
||||
--primary-color: #4361ee;
|
||||
--secondary-color: #3f37c9;
|
||||
--success-color: #4cc9f0;
|
||||
--warning-color: #f72585;
|
||||
--danger-color: #e63946;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8f9fa;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-weight: bold;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Navbar customization */
|
||||
.navbar {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Table improvements */
|
||||
.table {
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.table-hover tbody tr:hover {
|
||||
background-color: rgba(67, 97, 238, 0.05);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--secondary-color);
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
padding: 0.4em 0.8em;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-control:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 0.25rem rgba(67, 97, 238, 0.25);
|
||||
}
|
||||
|
||||
/* Alert enhancements */
|
||||
.alert {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Stats cards */
|
||||
.card.bg-primary,
|
||||
.card.bg-warning,
|
||||
.card.bg-info,
|
||||
.card.bg-danger {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.card.bg-primary:hover,
|
||||
.card.bg-warning:hover,
|
||||
.card.bg-info:hover,
|
||||
.card.bg-danger:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Tabs - Removed conflicted specific styles as they are managed in templates */
|
||||
.nav-tabs .nav-link {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
/* Login/Register cards */
|
||||
.card.shadow {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
/* Empty states */
|
||||
.text-muted.display-1 {
|
||||
color: #dee2e6 !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table td,
|
||||
.table th {
|
||||
padding: 0.35rem 0.5rem !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Animations for Anxiety/Urgency */
|
||||
@keyframes pulse-bg {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #ffe69c;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-bg-danger {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #f5c2c7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-text {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.anxiety-warning {
|
||||
animation: pulse-bg 2s infinite;
|
||||
}
|
||||
|
||||
.anxiety-danger {
|
||||
animation: pulse-bg-danger 1s infinite;
|
||||
}
|
||||
|
||||
/* Custom Table Styles - Compact */
|
||||
.custom-table thead th {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-size: 0.8rem;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.custom-table tbody td {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
vertical-align: middle;
|
||||
padding-top: 0.5rem !important;
|
||||
padding-bottom: 0.5rem !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Compact form elements */
|
||||
.form-control-custom,
|
||||
.form-select-custom,
|
||||
.btn-custom {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Custom Tab Styles */
|
||||
.nav-tabs .nav-link {
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
color: #6c757d;
|
||||
/* text-muted */
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #000;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #000 !important;
|
||||
font-weight: 700;
|
||||
border-bottom: 3px solid #000 !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Status Icon Size */
|
||||
.bi-circle,
|
||||
.bi-check-circle-fill {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Urgency styles for countdown */
|
||||
.countdown-urgent {
|
||||
color: #dc3545;
|
||||
font-weight: 700;
|
||||
animation: blink-text 1s infinite;
|
||||
}
|
||||
|
||||
.countdown-warning {
|
||||
color: #fd7e14;
|
||||
font-weight: 700;
|
||||
}
|
||||
33
web/static/js/app.js
Normal file
33
web/static/js/app.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// Homework Manager JavaScript
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-dismiss alerts after 5 seconds
|
||||
const alerts = document.querySelectorAll('.alert:not(.alert-danger)');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
alert.classList.add('fade');
|
||||
setTimeout(function() {
|
||||
alert.remove();
|
||||
}, 150);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Confirm dialogs for dangerous actions
|
||||
const confirmForms = document.querySelectorAll('form[data-confirm]');
|
||||
confirmForms.forEach(function(form) {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!confirm(form.dataset.confirm)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Set default datetime to now + 1 day for new assignments
|
||||
const dueDateInput = document.getElementById('due_date');
|
||||
if (dueDateInput && !dueDateInput.value) {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(23, 59, 0, 0);
|
||||
dueDateInput.value = tomorrow.toISOString().slice(0, 16);
|
||||
}
|
||||
});
|
||||
115
web/templates/admin/api_keys.html
Normal file
115
web/templates/admin/api_keys.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1 class="mb-4"><i class="bi bi-key me-2"></i>APIキー管理</h1>
|
||||
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
|
||||
{{if .newKey}}
|
||||
<div class="alert alert-success">
|
||||
<h5 class="alert-heading"><i class="bi bi-check-circle me-2"></i>APIキーが作成されました</h5>
|
||||
<p class="mb-2">キー名: <strong>{{.newKeyName}}</strong></p>
|
||||
<p class="mb-0">以下のキーを安全な場所に保存してください。このキーは二度と表示されません。</p>
|
||||
<hr>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="flex-grow-1 bg-dark text-light p-2 rounded me-2" id="newApiKey">{{.newKey}}</code>
|
||||
<button class="btn btn-outline-secondary" onclick="copyKey()"><i class="bi bi-clipboard"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-plus-circle me-2"></i>新規APIキー作成
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="/admin/api-keys" method="POST" class="row g-3">
|
||||
{{.csrfField}}
|
||||
<div class="col-md-8">
|
||||
<input type="text" class="form-control" name="name" placeholder="キー名(例: 外部連携用)" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-primary w-100"><i class="bi bi-plus me-1"></i>作成</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .apiKeys}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>キー名</th>
|
||||
<th>作成者</th>
|
||||
<th>最終使用</th>
|
||||
<th>作成日</th>
|
||||
<th style="width: 100px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .apiKeys}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><i class="bi bi-key me-1"></i>{{.Name}}</td>
|
||||
<td>{{if .User}}{{.User.Name}}{{else}}-{{end}}</td>
|
||||
<td>{{if .LastUsed}}{{formatDateTime .LastUsed}}{{else}}<span class="text-muted">未使用</span>{{end}}</td>
|
||||
<td>{{formatDate .CreatedAt}}</td>
|
||||
<td>
|
||||
<form action="/admin/api-keys/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('このAPIキーを削除しますか?')">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
|
||||
class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-key display-1 text-muted"></i>
|
||||
<h3 class="mt-3">APIキーがありません</h3>
|
||||
<p class="text-muted">上のフォームから新しいAPIキーを作成してください。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle me-2"></i>API使用方法
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-2">APIにアクセスするには、<code>Authorization</code>ヘッダーにAPIキーを設定してください:</p>
|
||||
<pre
|
||||
class="bg-dark text-light p-3 rounded"><code>curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8080/api/v1/assignments</code></pre>
|
||||
<h6 class="mt-3">利用可能なエンドポイント:</h6>
|
||||
<ul class="mb-0">
|
||||
<li><code>GET /api/v1/assignments</code> - 課題一覧取得</li>
|
||||
<li><code>GET /api/v1/assignments/pending</code> - 未完了の課題一覧</li>
|
||||
<li><code>GET /api/v1/assignments/completed</code> - 完了済みの課題一覧</li>
|
||||
<li><code>GET /api/v1/assignments/overdue</code> - 期限切れの課題一覧</li>
|
||||
<li><code>GET /api/v1/assignments/due-today</code> - 今日が期限の課題一覧</li>
|
||||
<li><code>GET /api/v1/assignments/due-this-week</code> - 今週中が期限の課題一覧</li>
|
||||
<li><code>GET /api/v1/assignments/:id</code> - 課題詳細取得</li>
|
||||
<li><code>POST /api/v1/assignments</code> - 課題作成</li>
|
||||
<li><code>PUT /api/v1/assignments/:id</code> - 課題更新</li>
|
||||
<li><code>DELETE /api/v1/assignments/:id</code> - 課題削除</li>
|
||||
<li><code>PATCH /api/v1/assignments/:id/toggle</code> - 完了状態切替</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
function copyKey() {
|
||||
const key = document.getElementById('newApiKey').innerText;
|
||||
navigator.clipboard.writeText(key).then(() => {
|
||||
alert('APIキーをコピーしました');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
65
web/templates/admin/users.html
Normal file
65
web/templates/admin/users.html
Normal file
@@ -0,0 +1,65 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1 class="mb-4"><i class="bi bi-people me-2"></i>ユーザー管理</h1>
|
||||
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
|
||||
{{if .users}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名前</th>
|
||||
<th>メールアドレス</th>
|
||||
<th>ロール</th>
|
||||
<th>登録日</th>
|
||||
<th style="width: 200px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .users}}
|
||||
<tr {{if eq .ID $.currentUserID}}class="table-primary" {{end}}>
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Name}}{{if eq .ID $.currentUserID}}<span class="badge bg-info ms-2">自分</span>{{end}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{if eq .Role "admin"}}<span class="badge bg-danger">管理者</span>{{else}}<span
|
||||
class="badge bg-secondary">ユーザー</span>{{end}}</td>
|
||||
<td>{{formatDate .CreatedAt}}</td>
|
||||
<td>
|
||||
{{if ne .ID $.currentUserID}}
|
||||
<form action="/admin/users/{{.ID}}/role" method="POST" class="d-inline" {{if eq .Role "admin"
|
||||
}}onsubmit="return confirm('このユーザーを一般ユーザーに降格しますか?')"
|
||||
{{else}}onsubmit="return confirm('このユーザーを管理者に昇格しますか?')" {{end}}>
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
{{if eq .Role "admin"}}
|
||||
<input type="hidden" name="role" value="user">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" title="ユーザーに降格"><i
|
||||
class="bi bi-arrow-down"></i></button>
|
||||
{{else}}
|
||||
<input type="hidden" name="role" value="admin">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning" title="管理者に昇格"><i
|
||||
class="bi bi-arrow-up"></i></button>
|
||||
{{end}}
|
||||
</form>
|
||||
<form action="/admin/users/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('このユーザーを削除しますか?')">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="削除"><i
|
||||
class="bi bi-trash"></i></button>
|
||||
</form>
|
||||
{{else}}<span class="text-muted">-</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people display-1 text-muted"></i>
|
||||
<h3 class="mt-3">ユーザーがいません</h3>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
51
web/templates/assignments/edit.html
Normal file
51
web/templates/assignments/edit.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-pencil me-2"></i>課題編集</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/assignments/{{.assignment.ID}}">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="title" name="title" value="{{.assignment.Title}}"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="subject" class="form-label">科目</label>
|
||||
<input type="text" class="form-control" id="subject" name="subject"
|
||||
value="{{.assignment.Subject}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="priority" class="form-label">重要度</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {{if eq .assignment.Priority "low" }}selected{{end}}>小</option>
|
||||
<option value="medium" {{if eq .assignment.Priority "medium" }}selected{{end}}>中</option>
|
||||
<option value="high" {{if eq .assignment.Priority "high" }}selected{{end}}>大</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
|
||||
<input type="datetime-local" class="form-control" id="due_date" name="due_date"
|
||||
value="{{formatDateInput .assignment.DueDate}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">説明</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3">{{.assignment.Description}}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
270
web/templates/assignments/index.html
Normal file
270
web/templates/assignments/index.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<h4 class="mb-0 fw-bold"><i class="bi bi-list-task me-2"></i>課題一覧</h4>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-secondary text-white" onclick="toggleCountdown()" id="toggleCountdownBtn">
|
||||
<i class="bi bi-clock me-1"></i><span id="countdownBtnText">カウントダウン表示中</span>
|
||||
</button>
|
||||
<a href="/assignments/new" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>新規登録
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs border-0 mb-2" id="assignmentTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " pending"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=pending&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " pending"}}color: black !important;{{end}}">未完了</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " completed"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=completed&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " completed"}}color: black !important;{{end}}">完了済み</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link py-2 rounded-0 border-0 {{if eq .filter " overdue"}}fw-bold border-bottom border-dark
|
||||
border-3{{else}}text-muted{{end}}" href="/assignments?filter=overdue&q={{.query}}&priority={{.priority}}"
|
||||
style="{{if eq .filter " overdue"}}color: black !important;{{end}}">期限切れ</a>
|
||||
</li>
|
||||
</ul>
|
||||
<hr class="mt-0 mb-3 text-muted" style="opacity: 0.1;">
|
||||
|
||||
<!-- Filter Section -->
|
||||
<form action="/assignments" method="GET" class="row g-2 mb-3 align-items-center">
|
||||
<input type="hidden" name="filter" value="{{.filter}}">
|
||||
<div class="col-md-5">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0 text-muted"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control border-start-0 ps-0 bg-white" name="q" placeholder="検索..."
|
||||
value="{{.query}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select class="form-select form-select-sm bg-white" name="priority" onchange="this.form.submit()">
|
||||
<option value="">全ての重要度</option>
|
||||
<option value="high" {{if eq .priority "high" }}selected{{end}}>高</option>
|
||||
<option value="medium" {{if eq .priority "medium" }}selected{{end}}>中</option>
|
||||
<option value="low" {{if eq .priority "low" }}selected{{end}}>低</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<a href="/assignments?filter={{.filter}}" class="btn btn-sm btn-outline-secondary w-100 bg-white">
|
||||
クリア
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card shadow-sm border-0 rounded-0">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0 custom-table">
|
||||
<thead class="bg-secondary-subtle">
|
||||
<tr>
|
||||
<th style="width: 50px;" class="ps-3 text-center text-dark fw-bold">状態</th>
|
||||
<th class="text-dark fw-bold">タイトル</th>
|
||||
<th style="width: 120px;" class="text-dark fw-bold">科目</th>
|
||||
<th style="width: 80px;" class="text-dark fw-bold">重要度</th>
|
||||
<th style="width: 140px;" class="text-dark fw-bold">期限</th>
|
||||
<th style="width: 120px;" class="countdown-col text-dark fw-bold">残り</th>
|
||||
<th style="width: 80px;" class="text-end pe-3 text-dark fw-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .assignments}}
|
||||
<tr class="assignment-row border-bottom" data-due-ts="{{.DueDate.Unix}}"
|
||||
data-completed="{{.IsCompleted}}">
|
||||
<td class="ps-3 text-center">
|
||||
{{if .IsCompleted}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit"
|
||||
class="btn btn-link p-0 text-success text-decoration-none hover-dark"
|
||||
title="未完了に戻す">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit"
|
||||
class="btn btn-link p-0 text-secondary text-decoration-none hover-dark"
|
||||
title="完了にする">
|
||||
<i class="bi bi-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-bold text-dark text-truncate" style="max-width: 300px;">{{.Title}}</div>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary text-white border-0 fw-bold">{{.Subject}}</span></td>
|
||||
<td>
|
||||
{{if eq .Priority "high"}}
|
||||
<span class="badge bg-danger text-white border-0 fw-bold small">高</span>
|
||||
{{else if eq .Priority "medium"}}
|
||||
<span class="badge bg-warning text-dark border-0 fw-bold small">中</span>
|
||||
{{else}}
|
||||
<span class="badge bg-dark text-white border-0 fw-bold small">低</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div class="small fw-bold text-dark user-select-all">{{.DueDate.Format "2006/01/02 15:04"}}
|
||||
</div>
|
||||
</td>
|
||||
<td class="countdown-col">
|
||||
{{if not .IsCompleted}}
|
||||
<span class="countdown small fw-bold font-monospace text-dark">...</span>
|
||||
{{else}}
|
||||
<span class="text-secondary small fw-bold">-</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<div class="btn-group">
|
||||
<a href="/assignments/{{.ID}}/edit" class="text-primary me-3 text-decoration-none">
|
||||
<i class="bi bi-pencil-fill"></i>
|
||||
</a>
|
||||
<form action="/assignments/{{.ID}}/delete" method="POST" class="d-inline"
|
||||
onsubmit="return confirm('削除しますか?');">
|
||||
<input type="hidden" name="_csrf" value="{{$.csrfToken}}">
|
||||
<button type="submit"
|
||||
class="btn btn-link p-0 text-danger text-decoration-none border-0 bg-transparent">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4 text-secondary fw-bold small">
|
||||
課題なし
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if gt .totalPages 1}}
|
||||
<div class="card-footer bg-white border-top-0 py-2">
|
||||
<nav>
|
||||
<ul class="pagination pagination-sm justify-content-center mb-0">
|
||||
<li class="page-item {{if not .hasPrev}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary"
|
||||
href="/assignments?page={{.prevPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link border-0 text-dark fw-bold">{{.currentPage}} / {{.totalPages}}</span>
|
||||
</li>
|
||||
<li class="page-item {{if not .hasNext}}disabled{{end}}">
|
||||
<a class="page-link border-0 text-secondary"
|
||||
href="/assignments?page={{.nextPage}}&filter={{.filter}}&q={{.query}}&priority={{.priority}}">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateCountdowns() {
|
||||
const now = new Date();
|
||||
document.querySelectorAll('.assignment-row').forEach(row => {
|
||||
if (row.getAttribute('data-completed') === 'true') return;
|
||||
|
||||
const dueTs = row.getAttribute('data-due-ts');
|
||||
if (!dueTs) return;
|
||||
|
||||
// Fix: Use timestamp directly to avoid parsing issues
|
||||
const due = new Date(parseInt(dueTs) * 1000);
|
||||
if (isNaN(due.getTime())) return;
|
||||
|
||||
const diff = due - now;
|
||||
const countdownEl = row.querySelector('.countdown');
|
||||
|
||||
// Reset classes
|
||||
row.classList.remove('anxiety-danger', 'anxiety-warning', 'bg-danger-subtle');
|
||||
if (countdownEl) countdownEl.className = 'countdown small fw-bold font-monospace';
|
||||
|
||||
if (diff < 0) {
|
||||
if (countdownEl) {
|
||||
countdownEl.textContent = "期限切れ";
|
||||
countdownEl.classList.add('text-danger');
|
||||
}
|
||||
row.classList.add('bg-danger-subtle');
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
let text = "";
|
||||
let remainingHours = (days * 24) + hours;
|
||||
|
||||
if (days > 0) {
|
||||
text += `${days}日 `;
|
||||
}
|
||||
text += `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
|
||||
if (countdownEl) countdownEl.textContent = text;
|
||||
|
||||
// Anxiety Logic
|
||||
if (remainingHours < 24) {
|
||||
row.classList.add('anxiety-danger');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-danger', 'countdown-urgent');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1"></i>' + text;
|
||||
}
|
||||
} else if (days < 7) {
|
||||
row.classList.add('anxiety-warning');
|
||||
if (countdownEl) {
|
||||
countdownEl.classList.add('text-dark');
|
||||
countdownEl.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i>' + text;
|
||||
}
|
||||
} else {
|
||||
if (countdownEl) countdownEl.classList.add('text-secondary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCountdown() {
|
||||
const cols = document.querySelectorAll('.countdown-col');
|
||||
const btnText = document.getElementById('countdownBtnText');
|
||||
const isHidden = cols[0] && cols[0].style.display === 'none';
|
||||
|
||||
cols.forEach(col => {
|
||||
col.style.display = isHidden ? '' : 'none';
|
||||
});
|
||||
|
||||
btnText.textContent = isHidden ? 'カウントダウン表示中' : 'カウントダウン非表示中';
|
||||
localStorage.setItem('countdownHidden', !isHidden);
|
||||
}
|
||||
|
||||
// Init with higher frequency for smooth panic
|
||||
setInterval(updateCountdowns, 1000);
|
||||
updateCountdowns();
|
||||
|
||||
// Check preference
|
||||
const isHidden = localStorage.getItem('countdownHidden') === 'true';
|
||||
if (isHidden) {
|
||||
document.querySelectorAll('.countdown-col').forEach(col => {
|
||||
col.style.display = 'none';
|
||||
});
|
||||
const btnText = document.getElementById('countdownBtnText');
|
||||
if (btnText) btnText.textContent = 'カウントダウン非表示中';
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
51
web/templates/assignments/new.html
Normal file
51
web/templates/assignments/new.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-plus-circle me-2"></i>課題登録</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
<form method="POST" action="/assignments">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">タイトル <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="title" name="title" value="{{.formTitle}}" required
|
||||
autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="subject" class="form-label">科目</label>
|
||||
<input type="text" class="form-control" id="subject" name="subject" value="{{.subject}}"
|
||||
placeholder="例: 数学、英語、情報">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="priority" class="form-label">重要度</label>
|
||||
<select class="form-select" id="priority" name="priority">
|
||||
<option value="low" {{if eq .priority "low" }}selected{{end}}>小</option>
|
||||
<option value="medium" {{if not (or (eq .priority "low" ) (eq .priority "high"
|
||||
))}}selected{{end}}>中</option>
|
||||
<option value="high" {{if eq .priority "high" }}selected{{end}}>大</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="due_date" class="form-label">提出期限 <span class="text-danger">*</span></label>
|
||||
<input type="datetime-local" class="form-control" id="due_date" name="due_date" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">説明</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="3">{{.description}}</textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>登録</button>
|
||||
<a href="/assignments" class="btn btn-outline-secondary">キャンセル</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
43
web/templates/auth/login.html
Normal file
43
web/templates/auth/login.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-journal-check display-4 text-primary"></i>
|
||||
<h2 class="mt-2">ログイン</h2>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
<div class="alert alert-danger">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">メールアドレス</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required
|
||||
autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">パスワード</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">ログイン</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-0">アカウントをお持ちでない方は</p>
|
||||
<a href="/register" class="btn btn-outline-secondary mt-2">新規登録</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
54
web/templates/auth/register.html
Normal file
54
web/templates/auth/register.html
Normal file
@@ -0,0 +1,54 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-person-plus display-4 text-primary"></i>
|
||||
<h2 class="mt-2">新規登録</h2>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
<div class="alert alert-danger">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/register">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">名前</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{.name}}" required
|
||||
autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">メールアドレス</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{.email}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">パスワード</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required
|
||||
minlength="6">
|
||||
<div class="form-text">6文字以上</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password_confirm" class="form-label">パスワード(確認)</label>
|
||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
|
||||
required minlength="6">
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">登録</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<p class="mb-0">既にアカウントをお持ちの方は</p>
|
||||
<a href="/login" class="btn btn-outline-secondary mt-2">ログイン</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
105
web/templates/layouts/base.html
Normal file
105
web/templates/layouts/base.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.title}} - Super Homework Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="/static/css/style.css" rel="stylesheet">
|
||||
<style>
|
||||
.navbar-dark .navbar-nav .nav-link,
|
||||
.navbar-brand {
|
||||
color: #fff !important;
|
||||
transition: color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover,
|
||||
.navbar-brand:hover {
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
</style>
|
||||
{{template "head" .}}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{{if .userName}}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<i class="bi bi-journal-check me-2"></i>Super Homework Manager
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/"><i class="bi bi-house-door me-1"></i>ダッシュボード</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/assignments"><i class="bi bi-list-task me-1"></i>課題一覧</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/assignments/new"><i class="bi bi-plus-circle me-1"></i>課題登録</a>
|
||||
</li>
|
||||
{{if .isAdmin}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/users"><i class="bi bi-people me-1"></i>ユーザー管理</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/api-keys"><i class="bi bi-key me-1"></i>APIキー管理</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-1"></i>{{.userName}}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="/profile"><i class="bi bi-person me-2"></i>プロフィール</a>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<form action="/logout" method="POST" class="d-inline">
|
||||
<input type="hidden" name="_csrf" value="{{.csrfToken}}">
|
||||
<button type="submit" class="dropdown-item"><i
|
||||
class="bi bi-box-arrow-right me-2"></i>ログアウト</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
<main class="container py-2">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer mt-auto py-1 bg-light">
|
||||
<div class="container text-center">
|
||||
<span class="text-muted small" style="font-size: 0.75rem;">Super Homework Manager</span><br>
|
||||
<small class="text-muted" style="font-size: 0.65rem;">Licensed under <a
|
||||
href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank">AGPLv3</a> | Time:
|
||||
{{.processing_time}}</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
{{template "scripts" .}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "head"}}{{end}}
|
||||
{{define "scripts"}}{{end}}
|
||||
300
web/templates/pages/dashboard.html
Normal file
300
web/templates/pages/dashboard.html
Normal file
@@ -0,0 +1,300 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "head"}}
|
||||
<style>
|
||||
@keyframes pulse-bg {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #ffe69c;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-bg-danger {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: #f5c2c7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink-banner {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.anxiety-warning {
|
||||
animation: pulse-bg 2s infinite;
|
||||
}
|
||||
|
||||
.anxiety-danger {
|
||||
animation: pulse-bg-danger 1s infinite;
|
||||
}
|
||||
|
||||
.urgent-banner {
|
||||
z-index: 1030;
|
||||
animation: blink-banner 1s infinite;
|
||||
}
|
||||
|
||||
.urgent-banner-danger {
|
||||
background: linear-gradient(90deg, #dc3545, #c82333);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.urgent-banner-warning {
|
||||
background: linear-gradient(90deg, #fd7e14, #e06c00);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.urgent-countdown {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div id="urgentBanner" class="urgent-banner py-3 text-center d-none">
|
||||
<div class="container">
|
||||
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
||||
<span id="urgentMessage"></span>
|
||||
<div class="urgent-countdown mt-1">
|
||||
<i class="bi bi-stopwatch"></i> あと <span id="urgentCountdown"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="mb-4"><i class="bi bi-house-door me-2"></i>ダッシュボード</h1>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-primary text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">未完了の課題</h6>
|
||||
<h2 class="mb-0">{{.stats.TotalPending}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-list-task display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-warning text-dark h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-dark-50">今日が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueToday}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-calendar-event display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-info text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">今週が期限</h6>
|
||||
<h2 class="mb-0">{{.stats.DueThisWeek}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-calendar-week display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="card bg-danger text-white h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="text-white-50">期限切れ</h6>
|
||||
<h2 class="mb-0">{{.stats.Overdue}}</h2>
|
||||
</div>
|
||||
<i class="bi bi-exclamation-triangle display-4 opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{{if .overdue}}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white"><i class="bi bi-exclamation-triangle me-2"></i>期限切れの課題</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .overdue}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-danger">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .dueToday}}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-warning">
|
||||
<div class="card-header bg-warning text-dark"><i class="bi bi-calendar-event me-2"></i>今日が期限</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .dueToday}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .upcoming}}
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-info">
|
||||
<div class="card-header bg-info text-white"><i class="bi bi-calendar-week me-2"></i>今週の課題</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
{{range .upcoming}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center"
|
||||
data-priority="{{.Priority}}" data-due="{{.DueDate.UnixMilli}}">
|
||||
<div>
|
||||
<strong>{{.Title}}</strong>
|
||||
{{if eq .Priority "high"}}<span class="badge bg-danger ms-1">重要</span>{{end}}
|
||||
{{if .Subject}}<span class="badge bg-secondary ms-2">{{.Subject}}</span>{{end}}
|
||||
<br><small class="text-muted">{{formatDateTime .DueDate}}</small>
|
||||
</div>
|
||||
<form action="/assignments/{{.ID}}/toggle" method="POST"><input type="hidden" name="_csrf"
|
||||
value="{{$.csrfToken}}"><button type="submit" class="btn btn-sm btn-success"
|
||||
title="完了にする"><i class="bi bi-check-lg"></i></button></form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if and (not .overdue) (not .dueToday) (not .upcoming)}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-emoji-smile display-1 text-success"></i>
|
||||
<h3 class="mt-3">今週の課題はありません!</h3>
|
||||
<p class="text-muted">新しい課題を登録しましょう</p>
|
||||
<a href="/assignments/new" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>課題を登録</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
(function () {
|
||||
var banner = document.getElementById('urgentBanner');
|
||||
var message = document.getElementById('urgentMessage');
|
||||
var countdown = document.getElementById('urgentCountdown');
|
||||
var body = document.body;
|
||||
|
||||
var items = document.querySelectorAll('[data-priority="high"][data-due]');
|
||||
var mostUrgent = null;
|
||||
var mostUrgentDue = Infinity;
|
||||
|
||||
items.forEach(function (item) {
|
||||
var due = parseInt(item.dataset.due);
|
||||
var now = Date.now();
|
||||
var diff = due - now;
|
||||
|
||||
if (diff > 0 && diff < mostUrgentDue) {
|
||||
mostUrgentDue = diff;
|
||||
var titleEl = item.querySelector('strong');
|
||||
mostUrgent = { due: due, title: titleEl ? titleEl.textContent : '課題' };
|
||||
}
|
||||
});
|
||||
|
||||
var hasOverdueHigh = false;
|
||||
var overdueItems = document.querySelectorAll('[data-priority="high"]');
|
||||
overdueItems.forEach(function (item) {
|
||||
var due = parseInt(item.dataset.due);
|
||||
if (due && due < Date.now()) {
|
||||
hasOverdueHigh = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasOverdueHigh) {
|
||||
banner.classList.remove('d-none');
|
||||
banner.classList.add('urgent-banner-danger');
|
||||
message.innerHTML = '🚨 <strong>期限切れの重要課題があります!</strong>';
|
||||
countdown.textContent = '今すぐ対応してください!';
|
||||
body.classList.add('anxiety-danger');
|
||||
} else if (mostUrgent && mostUrgentDue < 24 * 60 * 60 * 1000) {
|
||||
banner.classList.remove('d-none');
|
||||
banner.classList.add('urgent-banner-danger');
|
||||
message.innerHTML = '🚨 <strong>「' + mostUrgent.title + '」の期限が迫っています!</strong>';
|
||||
body.classList.add('anxiety-danger');
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
} else if (mostUrgent && mostUrgentDue < 3 * 24 * 60 * 60 * 1000) {
|
||||
banner.classList.remove('d-none');
|
||||
banner.classList.add('urgent-banner-warning');
|
||||
message.innerHTML = '⚠️ <strong>「' + mostUrgent.title + '」の期限が近づいています</strong>';
|
||||
body.classList.add('anxiety-warning');
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000);
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!mostUrgent) return;
|
||||
var now = Date.now();
|
||||
var diff = mostUrgent.due - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
countdown.textContent = '期限切れ!';
|
||||
return;
|
||||
}
|
||||
|
||||
var days = Math.floor(diff / 86400000);
|
||||
var hours = Math.floor((diff % 86400000) / 3600000);
|
||||
var mins = Math.floor((diff % 3600000) / 60000);
|
||||
var secs = Math.floor((diff % 60000) / 1000);
|
||||
|
||||
var text = '';
|
||||
if (days > 0) text = days + '日 ' + hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else if (hours > 0) text = hours + '時間 ' + mins + '分 ' + secs + '秒';
|
||||
else text = mins + '分 ' + secs + '秒';
|
||||
|
||||
countdown.textContent = text;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
10
web/templates/pages/error.html
Normal file
10
web/templates/pages/error.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle display-1 text-danger"></i>
|
||||
<h1 class="mt-4">{{.title}}</h1>
|
||||
<p class="lead text-muted">{{.message}}</p>
|
||||
<a href="/" class="btn btn-primary mt-3"><i class="bi bi-house-door me-1"></i>ダッシュボードに戻る</a>
|
||||
</div>
|
||||
{{end}}
|
||||
71
web/templates/pages/profile.html
Normal file
71
web/templates/pages/profile.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{template "base" .}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<h1 class="mb-4"><i class="bi bi-person me-2"></i>プロフィール</h1>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">アカウント情報</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .error}}<div class="alert alert-danger">{{.error}}</div>{{end}}
|
||||
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
|
||||
<form method="POST" action="/profile">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">メールアドレス</label>
|
||||
<input type="email" class="form-control" id="email" value="{{.user.Email}}" disabled>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">名前</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{.user.Name}}"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ロール</label>
|
||||
<input type="text" class="form-control"
|
||||
value="{{if eq .user.Role `admin`}}管理者{{else}}ユーザー{{end}}" disabled>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>更新</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">パスワード変更</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{if .passwordError}}<div class="alert alert-danger">{{.passwordError}}</div>{{end}}
|
||||
{{if .passwordSuccess}}<div class="alert alert-success">{{.passwordSuccess}}</div>{{end}}
|
||||
<form method="POST" action="/profile/password">
|
||||
{{.csrfField}}
|
||||
<div class="mb-3">
|
||||
<label for="old_password" class="form-label">現在のパスワード</label>
|
||||
<input type="password" class="form-control" id="old_password" name="old_password"
|
||||
required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">新しいパスワード</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password"
|
||||
required minlength="6">
|
||||
<div class="form-text">6文字以上</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">新しいパスワード(確認)</label>
|
||||
<input type="password" class="form-control" id="confirm_password"
|
||||
name="confirm_password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning"><i class="bi bi-key me-1"></i>パスワード変更</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user