From 0a37314fa8ae2423398e24b9d177568ef3bafd31 Mon Sep 17 00:00:00 2001 From: furu04 Date: Tue, 30 Dec 2025 21:47:39 +0900 Subject: [PATCH] first commit --- .dockerignore | 6 + .gitignore | 35 + Dockerfile | 42 ++ LICENSE.md | 651 +++++++++++++++++++ README.md | 96 +++ config.ini.example | 55 ++ docker-compose.yml | 11 + docs/API.md | 419 ++++++++++++ docs/SPECIFICATION.md | 252 +++++++ go.mod | 69 ++ go.sum | 174 +++++ internal/config/config.go | 182 ++++++ internal/database/database.go | 79 +++ internal/handler/admin_handler.go | 165 +++++ internal/handler/api_handler.go | 398 ++++++++++++ internal/handler/assignment_handler.go | 233 +++++++ internal/handler/auth_handler.go | 114 ++++ internal/handler/helper.go | 33 + internal/handler/profile_handler.go | 122 ++++ internal/middleware/auth.go | 121 ++++ internal/middleware/csrf.go | 119 ++++ internal/middleware/ratelimit.go | 94 +++ internal/middleware/security.go | 52 ++ internal/middleware/timer.go | 14 + internal/models/api_key.go | 19 + internal/models/assignment.go | 41 ++ internal/models/user.go | 28 + internal/repository/assignment_repository.go | 188 ++++++ internal/repository/user_repository.go | 61 ++ internal/router/router.go | 241 +++++++ internal/service/admin_service.go | 62 ++ internal/service/api_key_service.go | 89 +++ internal/service/assignment_service.go | 269 ++++++++ internal/service/auth_service.go | 106 +++ web/static/css/style.css | 280 ++++++++ web/static/js/app.js | 33 + web/templates/admin/api_keys.html | 115 ++++ web/templates/admin/users.html | 65 ++ web/templates/assignments/edit.html | 51 ++ web/templates/assignments/index.html | 270 ++++++++ web/templates/assignments/new.html | 51 ++ web/templates/auth/login.html | 43 ++ web/templates/auth/register.html | 54 ++ web/templates/layouts/base.html | 105 +++ web/templates/pages/dashboard.html | 300 +++++++++ web/templates/pages/error.html | 10 + web/templates/pages/profile.html | 71 ++ 47 files changed, 6088 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 config.ini.example create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/SPECIFICATION.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/database/database.go create mode 100644 internal/handler/admin_handler.go create mode 100644 internal/handler/api_handler.go create mode 100644 internal/handler/assignment_handler.go create mode 100644 internal/handler/auth_handler.go create mode 100644 internal/handler/helper.go create mode 100644 internal/handler/profile_handler.go create mode 100644 internal/middleware/auth.go create mode 100644 internal/middleware/csrf.go create mode 100644 internal/middleware/ratelimit.go create mode 100644 internal/middleware/security.go create mode 100644 internal/middleware/timer.go create mode 100644 internal/models/api_key.go create mode 100644 internal/models/assignment.go create mode 100644 internal/models/user.go create mode 100644 internal/repository/assignment_repository.go create mode 100644 internal/repository/user_repository.go create mode 100644 internal/router/router.go create mode 100644 internal/service/admin_service.go create mode 100644 internal/service/api_key_service.go create mode 100644 internal/service/assignment_service.go create mode 100644 internal/service/auth_service.go create mode 100644 web/static/css/style.css create mode 100644 web/static/js/app.js create mode 100644 web/templates/admin/api_keys.html create mode 100644 web/templates/admin/users.html create mode 100644 web/templates/assignments/edit.html create mode 100644 web/templates/assignments/index.html create mode 100644 web/templates/assignments/new.html create mode 100644 web/templates/auth/login.html create mode 100644 web/templates/auth/register.html create mode 100644 web/templates/layouts/base.html create mode 100644 web/templates/pages/dashboard.html create mode 100644 web/templates/pages/error.html create mode 100644 web/templates/pages/profile.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..61ee44d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +homework.db +config.ini +*.log +tmp/ +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3d1215 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bb0939d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..6124431 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,651 @@ +GNU Affero General Public License +================================= + +_Version 3, 19 November 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +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. + + + Copyright (C) + + 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 . + +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 +<>. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e107ddc --- /dev/null +++ b/README.md @@ -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 + 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ファイルをご覧ください。 \ No newline at end of file diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..c899a4b --- /dev/null +++ b/config.ini.example @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f2e2c6f --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..64bcc3b --- /dev/null +++ b/docs/API.md @@ -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`) または環境変数で制限値を変更可能です。 diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md new file mode 100644 index 0000000..4ed365e --- /dev/null +++ b/docs/SPECIFICATION.md @@ -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) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ead45ee --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..af3856a --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..501ee34 --- /dev/null +++ b/internal/config/config.go @@ -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 +} + diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..376869e --- /dev/null +++ b/internal/database/database.go @@ -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 +} + diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go new file mode 100644 index 0000000..a26619a --- /dev/null +++ b/internal/handler/admin_handler.go @@ -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") +} + diff --git a/internal/handler/api_handler.go b/internal/handler/api_handler.go new file mode 100644 index 0000000..e54960a --- /dev/null +++ b/internal/handler/api_handler.go @@ -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) +} diff --git a/internal/handler/assignment_handler.go b/internal/handler/assignment_handler.go new file mode 100644 index 0000000..14632b8 --- /dev/null +++ b/internal/handler/assignment_handler.go @@ -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") +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..54e3e6b --- /dev/null +++ b/internal/handler/auth_handler.go @@ -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") +} diff --git a/internal/handler/helper.go b/internal/handler/helper.go new file mode 100644 index 0000000..3b61487 --- /dev/null +++ b/internal/handler/helper.go @@ -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(``) + } + + c.HTML(code, name, obj) +} + diff --git a/internal/handler/profile_handler.go b/internal/handler/profile_handler.go new file mode 100644 index 0000000..54c2388 --- /dev/null +++ b/internal/handler/profile_handler.go @@ -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, + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..c495b0e --- /dev/null +++ b/internal/middleware/auth.go @@ -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 "}) + 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() + } +} + diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go new file mode 100644 index 0000000..185f891 --- /dev/null +++ b/internal/middleware/csrf.go @@ -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(``) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..87a0db7 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -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() + } +} diff --git a/internal/middleware/security.go b/internal/middleware/security.go new file mode 100644 index 0000000..69f89e3 --- /dev/null +++ b/internal/middleware/security.go @@ -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() + } +} diff --git a/internal/middleware/timer.go b/internal/middleware/timer.go new file mode 100644 index 0000000..6bf60b4 --- /dev/null +++ b/internal/middleware/timer.go @@ -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() + } +} diff --git a/internal/models/api_key.go b/internal/models/api_key.go new file mode 100644 index 0000000..a86a6ec --- /dev/null +++ b/internal/models/api_key.go @@ -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"` +} diff --git a/internal/models/assignment.go b/internal/models/assignment.go new file mode 100644 index 0000000..fe26be0 --- /dev/null +++ b/internal/models/assignment.go @@ -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) +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..4ca7c80 --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/internal/repository/assignment_repository.go b/internal/repository/assignment_repository.go new file mode 100644 index 0000000..1489006 --- /dev/null +++ b/internal/repository/assignment_repository.go @@ -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 +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..e2f033b --- /dev/null +++ b/internal/repository/user_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..3c417f9 --- /dev/null +++ b/internal/router/router.go @@ -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 +} diff --git a/internal/service/admin_service.go b/internal/service/admin_service.go new file mode 100644 index 0000000..e9573ee --- /dev/null +++ b/internal/service/admin_service.go @@ -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) +} diff --git a/internal/service/api_key_service.go b/internal/service/api_key_service.go new file mode 100644 index 0000000..b5a12f1 --- /dev/null +++ b/internal/service/api_key_service.go @@ -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 +} diff --git a/internal/service/assignment_service.go b/internal/service/assignment_service.go new file mode 100644 index 0000000..e9ef4d4 --- /dev/null +++ b/internal/service/assignment_service.go @@ -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 +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..987c92a --- /dev/null +++ b/internal/service/auth_service.go @@ -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) +} diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..922a645 --- /dev/null +++ b/web/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..97fb353 --- /dev/null +++ b/web/static/js/app.js @@ -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); + } +}); diff --git a/web/templates/admin/api_keys.html b/web/templates/admin/api_keys.html new file mode 100644 index 0000000..9aa6291 --- /dev/null +++ b/web/templates/admin/api_keys.html @@ -0,0 +1,115 @@ +{{template "base" .}} + +{{define "content"}} +

APIキー管理

+ +{{if .error}}
{{.error}}
{{end}} + +{{if .newKey}} +
+
APIキーが作成されました
+

キー名: {{.newKeyName}}

+

以下のキーを安全な場所に保存してください。このキーは二度と表示されません。

+
+
+ {{.newKey}} + +
+
+{{end}} + +
+
+ 新規APIキー作成 +
+
+
+ {{.csrfField}} +
+ +
+
+ +
+
+
+
+ +{{if .apiKeys}} +
+ + + + + + + + + + + + + {{range .apiKeys}} + + + + + + + + + {{end}} + +
IDキー名作成者最終使用作成日操作
{{.ID}}{{.Name}}{{if .User}}{{.User.Name}}{{else}}-{{end}}{{if .LastUsed}}{{formatDateTime .LastUsed}}{{else}}未使用{{end}}{{formatDate .CreatedAt}} +
+ + +
+
+
+{{else}} +
+ +

APIキーがありません

+

上のフォームから新しいAPIキーを作成してください。

+
+{{end}} + +
+
+ API使用方法 +
+
+

APIにアクセスするには、AuthorizationヘッダーにAPIキーを設定してください:

+
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:8080/api/v1/assignments
+
利用可能なエンドポイント:
+
    +
  • GET /api/v1/assignments - 課題一覧取得
  • +
  • GET /api/v1/assignments/pending - 未完了の課題一覧
  • +
  • GET /api/v1/assignments/completed - 完了済みの課題一覧
  • +
  • GET /api/v1/assignments/overdue - 期限切れの課題一覧
  • +
  • GET /api/v1/assignments/due-today - 今日が期限の課題一覧
  • +
  • GET /api/v1/assignments/due-this-week - 今週中が期限の課題一覧
  • +
  • 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 - 完了状態切替
  • +
+
+
+{{end}} + +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/admin/users.html b/web/templates/admin/users.html new file mode 100644 index 0000000..39b9908 --- /dev/null +++ b/web/templates/admin/users.html @@ -0,0 +1,65 @@ +{{template "base" .}} + +{{define "content"}} +

ユーザー管理

+ +{{if .error}}
{{.error}}
{{end}} + +{{if .users}} +
+ + + + + + + + + + + + + {{range .users}} + + + + + + + + + {{end}} + +
ID名前メールアドレスロール登録日操作
{{.ID}}{{.Name}}{{if eq .ID $.currentUserID}}自分{{end}}{{.Email}}{{if eq .Role "admin"}}管理者{{else}}ユーザー{{end}}{{formatDate .CreatedAt}} + {{if ne .ID $.currentUserID}} +
+ + {{if eq .Role "admin"}} + + + {{else}} + + + {{end}} +
+
+ + +
+ {{else}}-{{end}} +
+
+{{else}} +
+ +

ユーザーがいません

+
+{{end}} +{{end}} \ No newline at end of file diff --git a/web/templates/assignments/edit.html b/web/templates/assignments/edit.html new file mode 100644 index 0000000..0e840c1 --- /dev/null +++ b/web/templates/assignments/edit.html @@ -0,0 +1,51 @@ +{{template "base" .}} + +{{define "content"}} +
+
+
+
+
課題編集
+
+
+ {{if .error}}
{{.error}}
{{end}} +
+ {{.csrfField}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + キャンセル +
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/assignments/index.html b/web/templates/assignments/index.html new file mode 100644 index 0000000..0046334 --- /dev/null +++ b/web/templates/assignments/index.html @@ -0,0 +1,270 @@ +{{template "base" .}} + +{{define "content"}} +
+
+

課題一覧

+
+
+ + + 新規登録 + +
+
+ + + +
+ + +
+ +
+
+ + +
+
+
+ +
+ +
+ + +
+
+
+ + + + + + + + + + + + + + {{range .assignments}} + + + + + + + + + + {{else}} + + + + {{end}} + +
状態タイトル科目重要度期限残り操作
+ {{if .IsCompleted}} +
+ + +
+ {{else}} +
+ + +
+ {{end}} +
+
{{.Title}}
+
{{.Subject}} + {{if eq .Priority "high"}} + + {{else if eq .Priority "medium"}} + + {{else}} + + {{end}} + +
{{.DueDate.Format "2006/01/02 15:04"}} +
+
+ {{if not .IsCompleted}} + ... + {{else}} + - + {{end}} + +
+ + + +
+ + +
+
+
+ 課題なし +
+
+
+ {{if gt .totalPages 1}} + + {{end}} +
+ + +{{end}} \ No newline at end of file diff --git a/web/templates/assignments/new.html b/web/templates/assignments/new.html new file mode 100644 index 0000000..cc586ef --- /dev/null +++ b/web/templates/assignments/new.html @@ -0,0 +1,51 @@ +{{template "base" .}} + +{{define "content"}} +
+
+
+
+
課題登録
+
+
+ {{if .error}}
{{.error}}
{{end}} +
+ {{.csrfField}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + キャンセル +
+
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/auth/login.html b/web/templates/auth/login.html new file mode 100644 index 0000000..5941423 --- /dev/null +++ b/web/templates/auth/login.html @@ -0,0 +1,43 @@ +{{template "base" .}} + +{{define "content"}} +
+
+
+
+
+ +

ログイン

+
+ + {{if .error}} +
{{.error}}
+ {{end}} + +
+ {{.csrfField}} +
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+

アカウントをお持ちでない方は

+ 新規登録 +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/auth/register.html b/web/templates/auth/register.html new file mode 100644 index 0000000..b5ec462 --- /dev/null +++ b/web/templates/auth/register.html @@ -0,0 +1,54 @@ +{{template "base" .}} + +{{define "content"}} +
+
+
+
+
+ +

新規登録

+
+ + {{if .error}} +
{{.error}}
+ {{end}} + +
+ {{.csrfField}} +
+ + +
+
+ + +
+
+ + +
6文字以上
+
+
+ + +
+
+ +
+
+ +
+ +
+

既にアカウントをお持ちの方は

+ ログイン +
+
+
+
+
+{{end}} \ No newline at end of file diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html new file mode 100644 index 0000000..a1c1977 --- /dev/null +++ b/web/templates/layouts/base.html @@ -0,0 +1,105 @@ +{{define "base"}} + + + + + + + {{.title}} - Super Homework Manager + + + + + {{template "head" .}} + + + + {{if .userName}} + + {{end}} + +
+ {{template "content" .}} +
+ +
+
+ Super Homework Manager
+ Licensed under AGPLv3 | Time: + {{.processing_time}} +
+
+ + + + {{template "scripts" .}} + + + +{{end}} + +{{define "head"}}{{end}} +{{define "scripts"}}{{end}} \ No newline at end of file diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html new file mode 100644 index 0000000..2205925 --- /dev/null +++ b/web/templates/pages/dashboard.html @@ -0,0 +1,300 @@ +{{template "base" .}} + +{{define "head"}} + +{{end}} + +{{define "content"}} +
+
+ + +
+ あと +
+
+
+ +

ダッシュボード

+ +
+
+
+
+
+
+
未完了の課題
+

{{.stats.TotalPending}}

+
+ +
+
+
+
+
+
+
+
+
+
今日が期限
+

{{.stats.DueToday}}

+
+ +
+
+
+
+
+
+
+
+
+
今週が期限
+

{{.stats.DueThisWeek}}

+
+ +
+
+
+
+
+
+
+
+
+
期限切れ
+

{{.stats.Overdue}}

+
+ +
+
+
+
+
+ +
+ {{if .overdue}} +
+
+
期限切れの課題
+
    + {{range .overdue}} +
  • +
    + {{.Title}} + {{if eq .Priority "high"}}重要{{end}} + {{if .Subject}}{{.Subject}}{{end}} +
    {{formatDateTime .DueDate}} +
    +
    +
  • + {{end}} +
+
+
+ {{end}} + {{if .dueToday}} +
+
+
今日が期限
+
    + {{range .dueToday}} +
  • +
    + {{.Title}} + {{if eq .Priority "high"}}重要{{end}} + {{if .Subject}}{{.Subject}}{{end}} +
    {{formatDateTime .DueDate}} +
    +
    +
  • + {{end}} +
+
+
+ {{end}} + {{if .upcoming}} +
+
+
今週の課題
+
    + {{range .upcoming}} +
  • +
    + {{.Title}} + {{if eq .Priority "high"}}重要{{end}} + {{if .Subject}}{{.Subject}}{{end}} +
    {{formatDateTime .DueDate}} +
    +
    +
  • + {{end}} +
+
+
+ {{end}} +
+ +{{if and (not .overdue) (not .dueToday) (not .upcoming)}} +
+ +

今週の課題はありません!

+

新しい課題を登録しましょう

+ 課題を登録 +
+{{end}} +{{end}} + +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/pages/error.html b/web/templates/pages/error.html new file mode 100644 index 0000000..ec07e1a --- /dev/null +++ b/web/templates/pages/error.html @@ -0,0 +1,10 @@ +{{template "base" .}} + +{{define "content"}} +
+ +

{{.title}}

+

{{.message}}

+ ダッシュボードに戻る +
+{{end}} \ No newline at end of file diff --git a/web/templates/pages/profile.html b/web/templates/pages/profile.html new file mode 100644 index 0000000..3d929c9 --- /dev/null +++ b/web/templates/pages/profile.html @@ -0,0 +1,71 @@ +{{template "base" .}} + +{{define "content"}} +
+
+

プロフィール

+
+
+
+
+
アカウント情報
+
+
+ {{if .error}}
{{.error}}
{{end}} + {{if .success}}
{{.success}}
{{end}} +
+ {{.csrfField}} +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+
パスワード変更
+
+
+ {{if .passwordError}}
{{.passwordError}}
{{end}} + {{if .passwordSuccess}}
{{.passwordSuccess}}
{{end}} +
+ {{.csrfField}} +
+ + +
+
+ + +
6文字以上
+
+
+ + +
+ +
+
+
+
+
+
+
+{{end}} \ No newline at end of file